@stacksjs/ts-cloud 0.7.4 → 0.7.6
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/aws/index.js +259 -0
- package/dist/bin/cli.js +316 -316
- package/dist/chunk-0wxyppza.js +146 -0
- package/dist/chunk-3knnr7wh.js +601 -0
- package/dist/chunk-3rsfns7x.js +10427 -0
- package/dist/chunk-93hjhs78.js +1752 -0
- package/dist/chunk-arsh1g5h.js +1749 -0
- package/dist/chunk-b82pbxyp.js +1572 -0
- package/dist/chunk-c6rgvg1j.js +46124 -0
- package/dist/chunk-d2p5n2aq.js +23 -0
- package/dist/chunk-d7p84vz5.js +1699 -0
- package/dist/chunk-dpkk640m.js +4392 -0
- package/dist/chunk-eq08r166.js +8 -0
- package/dist/chunk-hsk6fe6x.js +371 -0
- package/dist/chunk-mj1tmhte.js +13 -0
- package/dist/chunk-p6309384.js +12828 -0
- package/dist/chunk-pegd7rmf.js +10 -0
- package/dist/chunk-pqfzdg68.js +12 -0
- package/dist/chunk-qpj3edwz.js +601 -0
- package/dist/chunk-tjjgajbh.js +1032 -0
- package/dist/chunk-tnztxpcb.js +630 -0
- package/dist/chunk-tt4kxske.js +1788 -0
- package/dist/chunk-v0bahtg2.js +4 -0
- package/dist/chunk-vd87cpvn.js +1754 -0
- package/dist/chunk-zn0nxxa8.js +1779 -0
- package/dist/deploy/index.js +87 -0
- package/dist/dns/index.js +31 -0
- package/dist/drivers/hetzner/provision.d.ts +53 -0
- package/dist/drivers/index.d.ts +5 -1
- package/dist/drivers/index.js +98 -0
- package/dist/drivers/shared/remote-exec.d.ts +47 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4383 -91358
- package/dist/push/index.js +509 -0
- package/dist/ui/index.html +3 -3
- package/dist/ui/server/actions.html +3 -3
- package/dist/ui/server/backups.html +3 -3
- package/dist/ui/server/database.html +3 -3
- package/dist/ui/server/deployments.html +3 -3
- package/dist/ui/server/firewall.html +3 -3
- package/dist/ui/server/logs.html +3 -3
- package/dist/ui/server/services.html +3 -3
- package/dist/ui/server/sites.html +3 -3
- package/dist/ui/server/ssh-keys.html +3 -3
- package/dist/ui/server/terminal.html +3 -3
- package/dist/ui/server/workers.html +3 -3
- package/dist/ui/serverless/alarms.html +3 -3
- package/dist/ui/serverless/assets.html +3 -3
- package/dist/ui/serverless/data.html +3 -3
- package/dist/ui/serverless/deployments.html +3 -3
- package/dist/ui/serverless/functions.html +3 -3
- package/dist/ui/serverless/logs.html +3 -3
- package/dist/ui/serverless/queues.html +3 -3
- package/dist/ui/serverless/scheduler.html +3 -3
- package/dist/ui/serverless/secrets.html +3 -3
- package/dist/ui/serverless/traces.html +3 -3
- package/dist/ui/serverless.html +3 -3
- package/package.json +3 -3
|
@@ -0,0 +1,1699 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CloudFrontClient
|
|
3
|
+
} from "./chunk-tjjgajbh.js";
|
|
4
|
+
import {
|
|
5
|
+
ACMClient,
|
|
6
|
+
ACMDnsValidator,
|
|
7
|
+
Route53Provider,
|
|
8
|
+
UnifiedDnsValidator,
|
|
9
|
+
createDnsProvider
|
|
10
|
+
} from "./chunk-tt4kxske.js";
|
|
11
|
+
import {
|
|
12
|
+
Route53Client
|
|
13
|
+
} from "./chunk-tnztxpcb.js";
|
|
14
|
+
import {
|
|
15
|
+
CloudFormationClient
|
|
16
|
+
} from "./chunk-qpj3edwz.js";
|
|
17
|
+
import {
|
|
18
|
+
S3Client
|
|
19
|
+
} from "./chunk-vd87cpvn.js";
|
|
20
|
+
import {
|
|
21
|
+
__require
|
|
22
|
+
} from "./chunk-v0bahtg2.js";
|
|
23
|
+
|
|
24
|
+
// src/deploy/static-site-external-dns.ts
|
|
25
|
+
function generateExternalDnsStaticSiteTemplate(config) {
|
|
26
|
+
const {
|
|
27
|
+
bucketName,
|
|
28
|
+
domain,
|
|
29
|
+
aliases,
|
|
30
|
+
certificateArn,
|
|
31
|
+
defaultRootObject = "index.html",
|
|
32
|
+
errorDocument = "404.html",
|
|
33
|
+
passthroughUrls = false,
|
|
34
|
+
singlePageApp = false,
|
|
35
|
+
dynamicApp = false,
|
|
36
|
+
computeOriginDomain,
|
|
37
|
+
computeOriginPort = 3008,
|
|
38
|
+
computeOriginId = "app-compute",
|
|
39
|
+
retainOnStackDelete = false
|
|
40
|
+
} = config;
|
|
41
|
+
const retainPolicy = retainOnStackDelete ? { DeletionPolicy: "Retain", UpdateReplacePolicy: "Retain" } : {};
|
|
42
|
+
const useComputeOrigin = dynamicApp && !!computeOriginDomain;
|
|
43
|
+
const defaultAllowedMethods = useComputeOrigin ? ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"] : ["GET", "HEAD"];
|
|
44
|
+
const defaultCachedMethods = useComputeOrigin ? ["GET", "HEAD"] : ["GET", "HEAD"];
|
|
45
|
+
const resources = {};
|
|
46
|
+
const outputs = {};
|
|
47
|
+
resources.S3Bucket = {
|
|
48
|
+
Type: "AWS::S3::Bucket",
|
|
49
|
+
...retainPolicy,
|
|
50
|
+
Properties: {
|
|
51
|
+
BucketName: bucketName,
|
|
52
|
+
PublicAccessBlockConfiguration: {
|
|
53
|
+
BlockPublicAcls: true,
|
|
54
|
+
BlockPublicPolicy: false,
|
|
55
|
+
IgnorePublicAcls: true,
|
|
56
|
+
RestrictPublicBuckets: false
|
|
57
|
+
},
|
|
58
|
+
WebsiteConfiguration: {
|
|
59
|
+
IndexDocument: defaultRootObject,
|
|
60
|
+
ErrorDocument: errorDocument
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
outputs.BucketName = {
|
|
65
|
+
Description: "S3 Bucket Name",
|
|
66
|
+
Value: { Ref: "S3Bucket" }
|
|
67
|
+
};
|
|
68
|
+
outputs.BucketArn = {
|
|
69
|
+
Description: "S3 Bucket ARN",
|
|
70
|
+
Value: { "Fn::GetAtt": ["S3Bucket", "Arn"] }
|
|
71
|
+
};
|
|
72
|
+
resources.CloudFrontOAC = {
|
|
73
|
+
Type: "AWS::CloudFront::OriginAccessControl",
|
|
74
|
+
...retainPolicy,
|
|
75
|
+
Properties: {
|
|
76
|
+
OriginAccessControlConfig: {
|
|
77
|
+
Name: `OAC-${bucketName}`,
|
|
78
|
+
Description: `OAC for ${bucketName}`,
|
|
79
|
+
OriginAccessControlOriginType: "s3",
|
|
80
|
+
SigningBehavior: "always",
|
|
81
|
+
SigningProtocol: "sigv4"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
let installRootRedirectLogicalId;
|
|
86
|
+
if (passthroughUrls && useComputeOrigin) {
|
|
87
|
+
installRootRedirectLogicalId = "InstallRootRedirectFunction";
|
|
88
|
+
resources[installRootRedirectLogicalId] = {
|
|
89
|
+
Type: "AWS::CloudFront::Function",
|
|
90
|
+
Properties: {
|
|
91
|
+
Name: { "Fn::Sub": "${AWS::StackName}-install-root-redirect" },
|
|
92
|
+
AutoPublish: true,
|
|
93
|
+
FunctionConfig: {
|
|
94
|
+
Comment: "Redirect curl pantry.dev | bash (GET /) to /install.sh on S3",
|
|
95
|
+
Runtime: "cloudfront-js-2.0"
|
|
96
|
+
},
|
|
97
|
+
FunctionCode: `function handler(event) {
|
|
98
|
+
var request = event.request;
|
|
99
|
+
if (request.uri === '/' || request.uri === '') {
|
|
100
|
+
return {
|
|
101
|
+
statusCode: 302,
|
|
102
|
+
statusDescription: 'Found',
|
|
103
|
+
headers: {
|
|
104
|
+
location: { value: 'https://' + request.headers.host.value + '/install.sh' }
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return request;
|
|
109
|
+
}`
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (!passthroughUrls) {
|
|
114
|
+
resources.UrlRewriteFunction = {
|
|
115
|
+
Type: "AWS::CloudFront::Function",
|
|
116
|
+
Properties: {
|
|
117
|
+
Name: { "Fn::Sub": "${AWS::StackName}-url-rewrite" },
|
|
118
|
+
AutoPublish: true,
|
|
119
|
+
FunctionConfig: {
|
|
120
|
+
Comment: "Append .html extension to URLs without extensions",
|
|
121
|
+
Runtime: "cloudfront-js-2.0"
|
|
122
|
+
},
|
|
123
|
+
FunctionCode: `function handler(event) {
|
|
124
|
+
const request = event.request;
|
|
125
|
+
var uri = request.uri;
|
|
126
|
+
|
|
127
|
+
// If URI ends with /, serve index.html
|
|
128
|
+
if (uri.endsWith('/')) {
|
|
129
|
+
request.uri = uri + 'index.html';
|
|
130
|
+
}
|
|
131
|
+
// If URI doesn't have an extension, append .html
|
|
132
|
+
else if (!uri.includes('.')) {
|
|
133
|
+
request.uri = uri + '.html';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return request;
|
|
137
|
+
}`
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const s3Origin = {
|
|
142
|
+
Id: `S3-${bucketName}`,
|
|
143
|
+
DomainName: { "Fn::GetAtt": ["S3Bucket", "RegionalDomainName"] },
|
|
144
|
+
S3OriginConfig: {
|
|
145
|
+
OriginAccessIdentity: ""
|
|
146
|
+
},
|
|
147
|
+
OriginAccessControlId: { "Fn::GetAtt": ["CloudFrontOAC", "Id"] }
|
|
148
|
+
};
|
|
149
|
+
const computeOrigin = useComputeOrigin ? {
|
|
150
|
+
Id: computeOriginId,
|
|
151
|
+
DomainName: computeOriginDomain,
|
|
152
|
+
CustomOriginConfig: {
|
|
153
|
+
HTTPPort: computeOriginPort,
|
|
154
|
+
HTTPSPort: 443,
|
|
155
|
+
OriginProtocolPolicy: "http-only",
|
|
156
|
+
OriginSSLProtocols: ["TLSv1.2"]
|
|
157
|
+
}
|
|
158
|
+
} : null;
|
|
159
|
+
const defaultTargetOriginId = useComputeOrigin ? computeOriginId : `S3-${bucketName}`;
|
|
160
|
+
const distributionConfig = {
|
|
161
|
+
Enabled: true,
|
|
162
|
+
DefaultRootObject: defaultRootObject,
|
|
163
|
+
HttpVersion: "http2and3",
|
|
164
|
+
IPV6Enabled: true,
|
|
165
|
+
PriceClass: "PriceClass_100",
|
|
166
|
+
Origins: computeOrigin ? [computeOrigin, s3Origin] : [s3Origin],
|
|
167
|
+
DefaultCacheBehavior: {
|
|
168
|
+
TargetOriginId: defaultTargetOriginId,
|
|
169
|
+
ViewerProtocolPolicy: "redirect-to-https",
|
|
170
|
+
AllowedMethods: defaultAllowedMethods,
|
|
171
|
+
CachedMethods: defaultCachedMethods,
|
|
172
|
+
Compress: true,
|
|
173
|
+
...useComputeOrigin ? {
|
|
174
|
+
CachePolicyId: "4135ea2d-6df8-44a3-9df3-4b5a84be39ad",
|
|
175
|
+
OriginRequestPolicyId: "b689b0a8-53d0-40ab-baf2-68738e2966ac",
|
|
176
|
+
...passthroughUrls && installRootRedirectLogicalId ? {
|
|
177
|
+
FunctionAssociations: [{
|
|
178
|
+
EventType: "viewer-request",
|
|
179
|
+
FunctionARN: { "Fn::GetAtt": [installRootRedirectLogicalId, "FunctionARN"] }
|
|
180
|
+
}]
|
|
181
|
+
} : {}
|
|
182
|
+
} : {
|
|
183
|
+
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
|
|
184
|
+
...!passthroughUrls && {
|
|
185
|
+
FunctionAssociations: [
|
|
186
|
+
{
|
|
187
|
+
EventType: "viewer-request",
|
|
188
|
+
FunctionARN: { "Fn::GetAtt": ["UrlRewriteFunction", "FunctionARN"] }
|
|
189
|
+
}
|
|
190
|
+
]
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
...useComputeOrigin && passthroughUrls ? {
|
|
195
|
+
CacheBehaviors: [
|
|
196
|
+
{
|
|
197
|
+
PathPattern: "/install.sh",
|
|
198
|
+
TargetOriginId: `S3-${bucketName}`,
|
|
199
|
+
ViewerProtocolPolicy: "redirect-to-https",
|
|
200
|
+
AllowedMethods: ["GET", "HEAD", "OPTIONS"],
|
|
201
|
+
CachedMethods: ["GET", "HEAD", "OPTIONS"],
|
|
202
|
+
Compress: true,
|
|
203
|
+
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6"
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
} : {},
|
|
207
|
+
CustomErrorResponses: singlePageApp ? [
|
|
208
|
+
{
|
|
209
|
+
ErrorCode: 403,
|
|
210
|
+
ResponseCode: 200,
|
|
211
|
+
ResponsePagePath: `/${defaultRootObject}`,
|
|
212
|
+
ErrorCachingMinTTL: 300
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
ErrorCode: 404,
|
|
216
|
+
ResponseCode: 200,
|
|
217
|
+
ResponsePagePath: `/${defaultRootObject}`,
|
|
218
|
+
ErrorCachingMinTTL: 300
|
|
219
|
+
}
|
|
220
|
+
] : [
|
|
221
|
+
{
|
|
222
|
+
ErrorCode: 403,
|
|
223
|
+
ResponseCode: 404,
|
|
224
|
+
ResponsePagePath: `/${errorDocument}`,
|
|
225
|
+
ErrorCachingMinTTL: 300
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
ErrorCode: 404,
|
|
229
|
+
ResponseCode: 404,
|
|
230
|
+
ResponsePagePath: `/${errorDocument}`,
|
|
231
|
+
ErrorCachingMinTTL: 300
|
|
232
|
+
}
|
|
233
|
+
]
|
|
234
|
+
};
|
|
235
|
+
if (domain && certificateArn) {
|
|
236
|
+
distributionConfig.Aliases = aliases && aliases.length > 0 ? aliases : [domain];
|
|
237
|
+
distributionConfig.ViewerCertificate = {
|
|
238
|
+
AcmCertificateArn: certificateArn,
|
|
239
|
+
SslSupportMethod: "sni-only",
|
|
240
|
+
MinimumProtocolVersion: "TLSv1.2_2021"
|
|
241
|
+
};
|
|
242
|
+
} else {
|
|
243
|
+
distributionConfig.ViewerCertificate = {
|
|
244
|
+
CloudFrontDefaultCertificate: true
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
resources.CloudFrontDistribution = {
|
|
248
|
+
Type: "AWS::CloudFront::Distribution",
|
|
249
|
+
...retainPolicy,
|
|
250
|
+
DependsOn: [
|
|
251
|
+
"S3Bucket",
|
|
252
|
+
"CloudFrontOAC",
|
|
253
|
+
...passthroughUrls && useComputeOrigin && installRootRedirectLogicalId ? [installRootRedirectLogicalId] : [],
|
|
254
|
+
...!passthroughUrls ? ["UrlRewriteFunction"] : []
|
|
255
|
+
],
|
|
256
|
+
Properties: {
|
|
257
|
+
DistributionConfig: distributionConfig
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
outputs.DistributionId = {
|
|
261
|
+
Description: "CloudFront Distribution ID",
|
|
262
|
+
Value: { Ref: "CloudFrontDistribution" }
|
|
263
|
+
};
|
|
264
|
+
outputs.DistributionDomain = {
|
|
265
|
+
Description: "CloudFront Distribution Domain",
|
|
266
|
+
Value: { "Fn::GetAtt": ["CloudFrontDistribution", "DomainName"] }
|
|
267
|
+
};
|
|
268
|
+
resources.S3BucketPolicy = {
|
|
269
|
+
Type: "AWS::S3::BucketPolicy",
|
|
270
|
+
...retainPolicy,
|
|
271
|
+
DependsOn: ["S3Bucket", "CloudFrontDistribution"],
|
|
272
|
+
Properties: {
|
|
273
|
+
Bucket: { Ref: "S3Bucket" },
|
|
274
|
+
PolicyDocument: {
|
|
275
|
+
Version: "2012-10-17",
|
|
276
|
+
Statement: [
|
|
277
|
+
{
|
|
278
|
+
Sid: "AllowCloudFrontServicePrincipal",
|
|
279
|
+
Effect: "Allow",
|
|
280
|
+
Principal: {
|
|
281
|
+
Service: "cloudfront.amazonaws.com"
|
|
282
|
+
},
|
|
283
|
+
Action: "s3:GetObject",
|
|
284
|
+
Resource: { "Fn::Sub": "arn:aws:s3:::${S3Bucket}/*" },
|
|
285
|
+
Condition: {
|
|
286
|
+
StringEquals: {
|
|
287
|
+
"AWS:SourceArn": {
|
|
288
|
+
"Fn::Sub": "arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}"
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
outputs.SiteUrl = {
|
|
298
|
+
Description: "Site URL",
|
|
299
|
+
Value: domain ? `https://${domain}` : { "Fn::Sub": "https://${CloudFrontDistribution.DomainName}" }
|
|
300
|
+
};
|
|
301
|
+
return {
|
|
302
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
303
|
+
Description: `Static site infrastructure for ${domain || bucketName} (External DNS)`,
|
|
304
|
+
Resources: resources,
|
|
305
|
+
Outputs: outputs
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
async function deployStaticSiteWithExternalDns(config) {
|
|
309
|
+
const region = config.region || "us-east-1";
|
|
310
|
+
const cfRegion = "us-east-1";
|
|
311
|
+
const domain = config.domain;
|
|
312
|
+
const bucket = config.bucket || domain.replace(/\./g, "-");
|
|
313
|
+
const stackName = config.stackName || `${config.siteName}-static-site`;
|
|
314
|
+
const cf = new CloudFormationClient(cfRegion);
|
|
315
|
+
const acm = new ACMClient("us-east-1");
|
|
316
|
+
let dnsProvider = null;
|
|
317
|
+
if (!config.skipDnsVerification) {
|
|
318
|
+
dnsProvider = createDnsProvider(config.dnsProvider);
|
|
319
|
+
console.log(`Verifying DNS provider can manage ${domain}...`);
|
|
320
|
+
const verify = await dnsProvider.listRecords(domain);
|
|
321
|
+
if (!verify.success) {
|
|
322
|
+
const reason = verify.message || "unknown error — check API credentials and domain ownership";
|
|
323
|
+
return {
|
|
324
|
+
success: false,
|
|
325
|
+
stackName,
|
|
326
|
+
bucket,
|
|
327
|
+
message: `DNS provider '${dnsProvider.name}' cannot manage domain ${domain}: ${reason}`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
console.log(`DNS provider '${dnsProvider.name}' verified for ${domain}`);
|
|
331
|
+
} else {
|
|
332
|
+
console.log(`Skipping DNS verification for ${domain}`);
|
|
333
|
+
}
|
|
334
|
+
let certificateArn = config.certificateArn;
|
|
335
|
+
const domainParts = domain.split(".");
|
|
336
|
+
const isApexDomain = domainParts.length === 2;
|
|
337
|
+
const wwwDomain = isApexDomain ? `www.${domain}` : undefined;
|
|
338
|
+
if (!certificateArn) {
|
|
339
|
+
console.log(`Checking for existing SSL certificate for ${domain}...`);
|
|
340
|
+
const existingCert = await acm.findCertificateByDomain(domain);
|
|
341
|
+
let existingCertCoversWww = false;
|
|
342
|
+
if (existingCert && existingCert.Status === "ISSUED") {
|
|
343
|
+
if (wwwDomain && existingCert.SubjectAlternativeNames) {
|
|
344
|
+
existingCertCoversWww = existingCert.SubjectAlternativeNames.includes(wwwDomain) || existingCert.SubjectAlternativeNames.some((san) => san === `*.${domain}`);
|
|
345
|
+
} else {
|
|
346
|
+
existingCertCoversWww = true;
|
|
347
|
+
}
|
|
348
|
+
if (existingCertCoversWww) {
|
|
349
|
+
certificateArn = existingCert.CertificateArn;
|
|
350
|
+
console.log(`Found existing certificate with www coverage: ${certificateArn}`);
|
|
351
|
+
} else {
|
|
352
|
+
console.log(`Existing certificate doesn't cover ${wwwDomain}, requesting new one...`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!certificateArn) {
|
|
356
|
+
if (!dnsProvider) {
|
|
357
|
+
return {
|
|
358
|
+
success: false,
|
|
359
|
+
stackName,
|
|
360
|
+
bucket,
|
|
361
|
+
message: `No DNS provider available to validate SSL certificate for ${domain}. Provide a certificateArn or enable DNS verification.`
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
console.log(`Requesting new SSL certificate for ${domain}${wwwDomain ? ` (including ${wwwDomain})` : ""}...`);
|
|
365
|
+
const validator = new UnifiedDnsValidator(dnsProvider, "us-east-1");
|
|
366
|
+
const certResult = await validator.findOrCreateCertificate({
|
|
367
|
+
domainName: domain,
|
|
368
|
+
subjectAlternativeNames: wwwDomain ? [wwwDomain] : undefined,
|
|
369
|
+
waitForValidation: true,
|
|
370
|
+
maxWaitMinutes: 10
|
|
371
|
+
});
|
|
372
|
+
if (certResult.status !== "issued") {
|
|
373
|
+
return {
|
|
374
|
+
success: false,
|
|
375
|
+
stackName,
|
|
376
|
+
bucket,
|
|
377
|
+
message: `SSL certificate validation failed. Status: ${certResult.status}`
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
certificateArn = certResult.certificateArn;
|
|
381
|
+
console.log(`Certificate issued: ${certificateArn}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
let stackExists = false;
|
|
385
|
+
let existingBucketName;
|
|
386
|
+
try {
|
|
387
|
+
const existingStacks = await cf.describeStacks({ stackName });
|
|
388
|
+
if (existingStacks.Stacks.length > 0) {
|
|
389
|
+
const stack = existingStacks.Stacks[0];
|
|
390
|
+
const stackStatus = stack.StackStatus;
|
|
391
|
+
if (stackStatus === "DELETE_IN_PROGRESS") {
|
|
392
|
+
console.log("Previous stack is still being deleted, waiting...");
|
|
393
|
+
await cf.waitForStack(stackName, "stack-delete-complete");
|
|
394
|
+
stackExists = false;
|
|
395
|
+
} else if (stackStatus === "DELETE_COMPLETE") {
|
|
396
|
+
stackExists = false;
|
|
397
|
+
} else {
|
|
398
|
+
stackExists = true;
|
|
399
|
+
const outputs2 = stack.Outputs || [];
|
|
400
|
+
existingBucketName = outputs2.find((o) => o.OutputKey === "BucketName")?.OutputValue;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (err.message?.includes("does not exist") || err.code === "ValidationError") {
|
|
405
|
+
stackExists = false;
|
|
406
|
+
} else {
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
let finalBucket = existingBucketName || bucket;
|
|
411
|
+
if (!stackExists) {
|
|
412
|
+
const s3 = new S3Client(region);
|
|
413
|
+
const cloudfront = new CloudFrontClient;
|
|
414
|
+
let hasExistingDistribution = false;
|
|
415
|
+
if (domain) {
|
|
416
|
+
try {
|
|
417
|
+
console.log(`Checking for existing CloudFront distributions with alias ${domain}...`);
|
|
418
|
+
const distributions = await cloudfront.listDistributions();
|
|
419
|
+
for (const dist of distributions) {
|
|
420
|
+
let aliases2 = [];
|
|
421
|
+
if (dist.Aliases?.Items) {
|
|
422
|
+
if (Array.isArray(dist.Aliases.Items)) {
|
|
423
|
+
aliases2 = dist.Aliases.Items;
|
|
424
|
+
} else if (typeof dist.Aliases.Items === "object") {
|
|
425
|
+
const cname = dist.Aliases.Items.CNAME;
|
|
426
|
+
if (typeof cname === "string") {
|
|
427
|
+
aliases2 = [cname];
|
|
428
|
+
} else if (Array.isArray(cname)) {
|
|
429
|
+
aliases2 = cname;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (aliases2.includes(domain)) {
|
|
434
|
+
hasExistingDistribution = true;
|
|
435
|
+
console.log(`Found existing CloudFront distribution ${dist.Id} with alias ${domain}`);
|
|
436
|
+
console.log(`Reusing existing infrastructure for updates...`);
|
|
437
|
+
const distConfig = await cloudfront.getDistributionConfig(dist.Id);
|
|
438
|
+
const originsData = distConfig.DistributionConfig?.Origins?.Items;
|
|
439
|
+
let originBucket;
|
|
440
|
+
if (originsData) {
|
|
441
|
+
let originList = [];
|
|
442
|
+
if (Array.isArray(originsData)) {
|
|
443
|
+
originList = originsData;
|
|
444
|
+
} else if (originsData.Origin) {
|
|
445
|
+
originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin];
|
|
446
|
+
} else {
|
|
447
|
+
originList = [originsData];
|
|
448
|
+
}
|
|
449
|
+
for (const origin of originList) {
|
|
450
|
+
const domainName = origin.DomainName || "";
|
|
451
|
+
const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/);
|
|
452
|
+
if (s3Match) {
|
|
453
|
+
originBucket = s3Match[1];
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (originBucket) {
|
|
459
|
+
console.log(`Using existing S3 bucket: ${originBucket}`);
|
|
460
|
+
return {
|
|
461
|
+
success: true,
|
|
462
|
+
stackName: `existing-${dist.Id}`,
|
|
463
|
+
bucket: originBucket,
|
|
464
|
+
distributionId: dist.Id,
|
|
465
|
+
distributionDomain: dist.DomainName,
|
|
466
|
+
domain,
|
|
467
|
+
certificateArn,
|
|
468
|
+
message: "Using existing CloudFront distribution",
|
|
469
|
+
_existingInfrastructure: true
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
} catch {}
|
|
475
|
+
}
|
|
476
|
+
if (!hasExistingDistribution) {
|
|
477
|
+
try {
|
|
478
|
+
const headResult = await s3.headBucket(bucket);
|
|
479
|
+
if (headResult.exists) {
|
|
480
|
+
console.log(`Found orphaned S3 bucket ${bucket}, cleaning up...`);
|
|
481
|
+
try {
|
|
482
|
+
const cleanupPromise = s3.emptyBucket(bucket).then(() => s3.deleteBucket(bucket));
|
|
483
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Bucket cleanup timeout")), 30000));
|
|
484
|
+
await Promise.race([cleanupPromise, timeoutPromise]);
|
|
485
|
+
console.log(`Deleted orphaned S3 bucket ${bucket}`);
|
|
486
|
+
} catch (cleanupErr) {
|
|
487
|
+
console.log(`Note: Could not clean up S3 bucket: ${cleanupErr.message}`);
|
|
488
|
+
const suffix = Date.now().toString(36);
|
|
489
|
+
finalBucket = `${bucket}-${suffix}`;
|
|
490
|
+
console.log(`Using alternative bucket name: ${finalBucket}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} catch {}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const aliases = wwwDomain ? [domain, wwwDomain] : [domain];
|
|
497
|
+
const template = generateExternalDnsStaticSiteTemplate({
|
|
498
|
+
bucketName: finalBucket,
|
|
499
|
+
domain,
|
|
500
|
+
aliases,
|
|
501
|
+
certificateArn,
|
|
502
|
+
defaultRootObject: config.defaultRootObject,
|
|
503
|
+
errorDocument: config.errorDocument,
|
|
504
|
+
passthroughUrls: config.passthroughUrls,
|
|
505
|
+
singlePageApp: config.singlePageApp,
|
|
506
|
+
dynamicApp: config.dynamicApp,
|
|
507
|
+
computeOriginDomain: config.computeOriginDomain,
|
|
508
|
+
computeOriginPort: config.computeOriginPort,
|
|
509
|
+
computeOriginId: config.computeOriginId
|
|
510
|
+
});
|
|
511
|
+
const tags = Object.entries(config.tags || {}).map(([Key, Value]) => ({ Key, Value }));
|
|
512
|
+
tags.push({ Key: "ManagedBy", Value: "ts-cloud" });
|
|
513
|
+
tags.push({ Key: "Application", Value: config.siteName });
|
|
514
|
+
if (dnsProvider) {
|
|
515
|
+
tags.push({ Key: "DnsProvider", Value: dnsProvider.name });
|
|
516
|
+
}
|
|
517
|
+
let stackId;
|
|
518
|
+
let isUpdate = false;
|
|
519
|
+
if (stackExists) {
|
|
520
|
+
isUpdate = true;
|
|
521
|
+
console.log(`Updating CloudFormation stack: ${stackName}`);
|
|
522
|
+
try {
|
|
523
|
+
const result = await cf.updateStack({
|
|
524
|
+
stackName,
|
|
525
|
+
templateBody: JSON.stringify(template),
|
|
526
|
+
capabilities: ["CAPABILITY_IAM"],
|
|
527
|
+
tags
|
|
528
|
+
});
|
|
529
|
+
stackId = result.StackId;
|
|
530
|
+
console.log(`Update initiated, stack ID: ${stackId}`);
|
|
531
|
+
} catch (err) {
|
|
532
|
+
if (err.message?.includes("No updates are to be performed")) {
|
|
533
|
+
const stacks2 = await cf.describeStacks({ stackName });
|
|
534
|
+
stackId = stacks2.Stacks[0].StackId;
|
|
535
|
+
const outputs2 = stacks2.Stacks[0]?.Outputs || [];
|
|
536
|
+
const getOutput2 = (key) => outputs2.find((o) => o.OutputKey === key)?.OutputValue;
|
|
537
|
+
const distributionDomain2 = getOutput2("DistributionDomain");
|
|
538
|
+
if (distributionDomain2) {
|
|
539
|
+
await ensureDnsRecords(dnsProvider, domain, distributionDomain2);
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
success: true,
|
|
543
|
+
stackId,
|
|
544
|
+
stackName,
|
|
545
|
+
bucket: getOutput2("BucketName") || finalBucket,
|
|
546
|
+
distributionId: getOutput2("DistributionId"),
|
|
547
|
+
distributionDomain: distributionDomain2,
|
|
548
|
+
domain,
|
|
549
|
+
certificateArn,
|
|
550
|
+
message: "Static site infrastructure is already up to date"
|
|
551
|
+
};
|
|
552
|
+
} else {
|
|
553
|
+
throw err;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
} else {
|
|
557
|
+
console.log(`Creating CloudFormation stack: ${stackName}`);
|
|
558
|
+
console.log(`Bucket name: ${finalBucket}`);
|
|
559
|
+
console.log(`Domain: ${domain}`);
|
|
560
|
+
console.log(`Certificate ARN: ${certificateArn}`);
|
|
561
|
+
const result = await cf.createStack({
|
|
562
|
+
stackName,
|
|
563
|
+
templateBody: JSON.stringify(template),
|
|
564
|
+
capabilities: ["CAPABILITY_IAM"],
|
|
565
|
+
tags,
|
|
566
|
+
onFailure: "DELETE"
|
|
567
|
+
});
|
|
568
|
+
stackId = result.StackId;
|
|
569
|
+
console.log(`Create initiated, stack ID: ${stackId}`);
|
|
570
|
+
}
|
|
571
|
+
console.log(`Waiting for stack to reach ${isUpdate ? "stack-update-complete" : "stack-create-complete"}...`);
|
|
572
|
+
try {
|
|
573
|
+
await cf.waitForStack(stackName, isUpdate ? "stack-update-complete" : "stack-create-complete");
|
|
574
|
+
console.log("Stack operation completed successfully!");
|
|
575
|
+
} catch (err) {
|
|
576
|
+
if (err.message?.includes("must be verified") || err.message?.includes("Access denied for operation")) {
|
|
577
|
+
console.log("CloudFront account verification required - checking for existing infrastructure...");
|
|
578
|
+
try {
|
|
579
|
+
const cloudfront = new CloudFrontClient;
|
|
580
|
+
const distributions = await cloudfront.listDistributions();
|
|
581
|
+
for (const dist of distributions) {
|
|
582
|
+
let aliases2 = [];
|
|
583
|
+
if (dist.Aliases?.Items) {
|
|
584
|
+
if (Array.isArray(dist.Aliases.Items)) {
|
|
585
|
+
aliases2 = dist.Aliases.Items;
|
|
586
|
+
} else if (typeof dist.Aliases.Items === "object") {
|
|
587
|
+
const cname = dist.Aliases.Items.CNAME;
|
|
588
|
+
if (typeof cname === "string") {
|
|
589
|
+
aliases2 = [cname];
|
|
590
|
+
} else if (Array.isArray(cname)) {
|
|
591
|
+
aliases2 = cname;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (aliases2.includes(domain)) {
|
|
596
|
+
console.log(`Found existing CloudFront distribution ${dist.Id} with alias ${domain}`);
|
|
597
|
+
console.log(`Using existing infrastructure despite account verification requirement...`);
|
|
598
|
+
const distConfig = await cloudfront.getDistributionConfig(dist.Id);
|
|
599
|
+
const originsData = distConfig.DistributionConfig?.Origins?.Items;
|
|
600
|
+
let originBucket;
|
|
601
|
+
if (originsData) {
|
|
602
|
+
let originList = [];
|
|
603
|
+
if (Array.isArray(originsData)) {
|
|
604
|
+
originList = originsData;
|
|
605
|
+
} else if (originsData.Origin) {
|
|
606
|
+
originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin];
|
|
607
|
+
} else {
|
|
608
|
+
originList = [originsData];
|
|
609
|
+
}
|
|
610
|
+
for (const origin of originList) {
|
|
611
|
+
const domainName = origin.DomainName || "";
|
|
612
|
+
const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/);
|
|
613
|
+
if (s3Match) {
|
|
614
|
+
originBucket = s3Match[1];
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (originBucket) {
|
|
620
|
+
console.log(`Using existing S3 bucket: ${originBucket}`);
|
|
621
|
+
const distributionDomain2 = dist.DomainName;
|
|
622
|
+
if (distributionDomain2) {
|
|
623
|
+
await ensureDnsRecords(dnsProvider, domain, distributionDomain2);
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
success: true,
|
|
627
|
+
stackName: `existing-${dist.Id}`,
|
|
628
|
+
bucket: originBucket,
|
|
629
|
+
distributionId: dist.Id,
|
|
630
|
+
distributionDomain: dist.DomainName,
|
|
631
|
+
domain,
|
|
632
|
+
certificateArn,
|
|
633
|
+
message: "Using existing CloudFront distribution (account verification pending for new distributions)"
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
} catch {}
|
|
639
|
+
const verificationMessage = `
|
|
640
|
+
┌─────────────────────────────────────────────────────────────────────────────┐
|
|
641
|
+
│ AWS ACCOUNT VERIFICATION REQUIRED │
|
|
642
|
+
├─────────────────────────────────────────────────────────────────────────────┤
|
|
643
|
+
│ Your AWS account must be verified before you can create CloudFront │
|
|
644
|
+
│ distributions. This is a one-time requirement for new AWS accounts. │
|
|
645
|
+
│ │
|
|
646
|
+
│ To verify your account: │
|
|
647
|
+
│ │
|
|
648
|
+
│ 1. Go to: https://console.aws.amazon.com/support/home#/ │
|
|
649
|
+
│ 2. Click "Create case" │
|
|
650
|
+
│ 3. Select "Service limit increase" │
|
|
651
|
+
│ 4. For Service: Select "CloudFront" │
|
|
652
|
+
│ 5. For Request: "Please verify my account for CloudFront access" │
|
|
653
|
+
│ 6. Submit the case │
|
|
654
|
+
│ │
|
|
655
|
+
│ Verification typically takes 1-2 business days. │
|
|
656
|
+
│ After verification, re-run: bunx bunpress deploy │
|
|
657
|
+
└─────────────────────────────────────────────────────────────────────────────┘`;
|
|
658
|
+
console.log(verificationMessage);
|
|
659
|
+
return {
|
|
660
|
+
success: false,
|
|
661
|
+
stackId,
|
|
662
|
+
stackName,
|
|
663
|
+
bucket: finalBucket,
|
|
664
|
+
message: "CloudFront account verification required. Please contact AWS Support."
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
success: false,
|
|
669
|
+
stackId,
|
|
670
|
+
stackName,
|
|
671
|
+
bucket: finalBucket,
|
|
672
|
+
message: `Stack deployment failed: ${err.message}`
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
const stacks = await cf.describeStacks({ stackName });
|
|
676
|
+
const outputs = stacks.Stacks[0]?.Outputs || [];
|
|
677
|
+
const getOutput = (key) => outputs.find((o) => o.OutputKey === key)?.OutputValue;
|
|
678
|
+
const distributionDomain = getOutput("DistributionDomain");
|
|
679
|
+
if (distributionDomain && dnsProvider) {
|
|
680
|
+
console.log(`Creating DNS records via ${dnsProvider.name}...`);
|
|
681
|
+
await ensureDnsRecords(dnsProvider, domain, distributionDomain);
|
|
682
|
+
} else if (distributionDomain && !dnsProvider) {
|
|
683
|
+
console.log(`Skipping DNS record creation (DNS verification was skipped)`);
|
|
684
|
+
}
|
|
685
|
+
return {
|
|
686
|
+
success: true,
|
|
687
|
+
stackId,
|
|
688
|
+
stackName,
|
|
689
|
+
bucket: getOutput("BucketName") || finalBucket,
|
|
690
|
+
distributionId: getOutput("DistributionId"),
|
|
691
|
+
distributionDomain,
|
|
692
|
+
domain,
|
|
693
|
+
certificateArn,
|
|
694
|
+
message: "Static site infrastructure deployed successfully with external DNS"
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
async function ensureDnsRecords(dnsProvider, domain, cloudfrontDomain) {
|
|
698
|
+
if (!dnsProvider) {
|
|
699
|
+
console.log(`Skipping DNS record creation (DNS verification was skipped)`);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const parts = domain.split(".");
|
|
703
|
+
const isApexDomain = parts.length === 2;
|
|
704
|
+
if (isApexDomain) {
|
|
705
|
+
if (dnsProvider instanceof Route53Provider) {
|
|
706
|
+
console.log(`Creating Route53 alias record for apex domain ${domain} -> ${cloudfrontDomain}`);
|
|
707
|
+
const result = await dnsProvider.createCloudFrontAlias({
|
|
708
|
+
domain,
|
|
709
|
+
name: domain,
|
|
710
|
+
cloudFrontDomainName: cloudfrontDomain
|
|
711
|
+
});
|
|
712
|
+
if (!result.success) {
|
|
713
|
+
console.warn(`Warning: Could not create Route53 alias record: ${result.message}`);
|
|
714
|
+
console.warn(`Please manually create an A-record alias in Route53:`);
|
|
715
|
+
console.warn(` ${domain} -> ${cloudfrontDomain}`);
|
|
716
|
+
} else {
|
|
717
|
+
console.log(`Created Route53 alias record: ${domain} -> ${cloudfrontDomain}`);
|
|
718
|
+
}
|
|
719
|
+
const wwwDomain = `www.${domain}`;
|
|
720
|
+
console.log(`Creating Route53 alias record for ${wwwDomain} -> ${cloudfrontDomain}`);
|
|
721
|
+
const wwwResult = await dnsProvider.createCloudFrontAlias({
|
|
722
|
+
domain,
|
|
723
|
+
name: wwwDomain,
|
|
724
|
+
cloudFrontDomainName: cloudfrontDomain
|
|
725
|
+
});
|
|
726
|
+
if (!wwwResult.success) {
|
|
727
|
+
console.warn(`Warning: Could not create www Route53 alias: ${wwwResult.message}`);
|
|
728
|
+
console.warn(`Please manually create an A-record alias in Route53:`);
|
|
729
|
+
console.warn(` ${wwwDomain} -> ${cloudfrontDomain}`);
|
|
730
|
+
} else {
|
|
731
|
+
console.log(`Created Route53 alias record: ${wwwDomain} -> ${cloudfrontDomain}`);
|
|
732
|
+
}
|
|
733
|
+
} else {
|
|
734
|
+
try {
|
|
735
|
+
const existingA = await dnsProvider.listRecords(domain, "A");
|
|
736
|
+
if (existingA.success) {
|
|
737
|
+
for (const rec of existingA.records) {
|
|
738
|
+
const recSub = rec.name.replace(/\.$/, "");
|
|
739
|
+
if (recSub === domain || recSub === "") {
|
|
740
|
+
console.log(`Removing existing root A record (${rec.content}) to make room for ALIAS...`);
|
|
741
|
+
await dnsProvider.deleteRecord(domain, {
|
|
742
|
+
name: domain,
|
|
743
|
+
type: "A",
|
|
744
|
+
content: rec.content
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
} catch (err) {
|
|
750
|
+
console.log(`Note: Could not check for conflicting A records: ${err instanceof Error ? err.message : err}`);
|
|
751
|
+
}
|
|
752
|
+
console.log(`Creating ALIAS record for apex domain ${domain} -> ${cloudfrontDomain}`);
|
|
753
|
+
const result = await dnsProvider.upsertRecord(domain, {
|
|
754
|
+
name: domain,
|
|
755
|
+
type: "ALIAS",
|
|
756
|
+
content: cloudfrontDomain,
|
|
757
|
+
ttl: 600
|
|
758
|
+
});
|
|
759
|
+
if (!result.success) {
|
|
760
|
+
console.warn(`Warning: Could not create ALIAS record: ${result.message}`);
|
|
761
|
+
console.warn(`Please manually create an ALIAS record in your DNS provider:`);
|
|
762
|
+
console.warn(` ${domain} -> ${cloudfrontDomain}`);
|
|
763
|
+
} else {
|
|
764
|
+
console.log(`Created ALIAS record: ${domain} -> ${cloudfrontDomain}`);
|
|
765
|
+
}
|
|
766
|
+
const wwwDomain = `www.${domain}`;
|
|
767
|
+
console.log(`Creating CNAME record for ${wwwDomain} -> ${cloudfrontDomain}`);
|
|
768
|
+
const wwwResult = await dnsProvider.upsertRecord(domain, {
|
|
769
|
+
name: wwwDomain,
|
|
770
|
+
type: "CNAME",
|
|
771
|
+
content: cloudfrontDomain,
|
|
772
|
+
ttl: 600
|
|
773
|
+
});
|
|
774
|
+
if (!wwwResult.success) {
|
|
775
|
+
console.warn(`Warning: Could not create www DNS record: ${wwwResult.message}`);
|
|
776
|
+
console.warn(`Please manually create a CNAME record:`);
|
|
777
|
+
console.warn(` ${wwwDomain} -> ${cloudfrontDomain}`);
|
|
778
|
+
} else {
|
|
779
|
+
console.log(`Created CNAME record: ${wwwDomain} -> ${cloudfrontDomain}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
} else {
|
|
783
|
+
console.log(`Creating CNAME record for ${domain} -> ${cloudfrontDomain}`);
|
|
784
|
+
const result = await dnsProvider.upsertRecord(domain, {
|
|
785
|
+
name: domain,
|
|
786
|
+
type: "CNAME",
|
|
787
|
+
content: cloudfrontDomain,
|
|
788
|
+
ttl: 600
|
|
789
|
+
});
|
|
790
|
+
if (!result.success) {
|
|
791
|
+
console.warn(`Warning: Could not create DNS record: ${result.message}`);
|
|
792
|
+
console.warn(`Please manually create a CNAME record:`);
|
|
793
|
+
console.warn(` ${domain} -> ${cloudfrontDomain}`);
|
|
794
|
+
} else {
|
|
795
|
+
console.log(`Created CNAME record: ${domain} -> ${cloudfrontDomain}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
async function deployStaticSiteWithExternalDnsFull(config) {
|
|
800
|
+
const { sourceDir, cleanBucket = false, onProgress, ...siteConfig } = config;
|
|
801
|
+
onProgress?.("infrastructure", "Deploying CloudFormation stack...");
|
|
802
|
+
const infraResult = await deployStaticSiteWithExternalDns(siteConfig);
|
|
803
|
+
if (!infraResult.success) {
|
|
804
|
+
return infraResult;
|
|
805
|
+
}
|
|
806
|
+
if (cleanBucket) {
|
|
807
|
+
onProgress?.("clean", "Cleaning old files from S3...");
|
|
808
|
+
try {
|
|
809
|
+
const s3 = new S3Client(siteConfig.region || "us-east-1");
|
|
810
|
+
await s3.emptyBucket(infraResult.bucket);
|
|
811
|
+
} catch (err) {
|
|
812
|
+
console.log(`Note: Could not clean bucket: ${err.message}`);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
onProgress?.("upload", "Uploading files to S3...");
|
|
816
|
+
const { uploadStaticFiles } = await import("./chunk-d2p5n2aq.js");
|
|
817
|
+
const uploadResult = await uploadStaticFiles({
|
|
818
|
+
sourceDir,
|
|
819
|
+
bucket: infraResult.bucket,
|
|
820
|
+
region: siteConfig.region || "us-east-1",
|
|
821
|
+
cacheControl: siteConfig.cacheControl,
|
|
822
|
+
onProgress: (uploaded, total, file) => {
|
|
823
|
+
onProgress?.("upload", `${uploaded}/${total}: ${file}`);
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
if (uploadResult.errors.length > 0) {
|
|
827
|
+
return {
|
|
828
|
+
...infraResult,
|
|
829
|
+
success: false,
|
|
830
|
+
message: `Upload errors: ${uploadResult.errors.join(", ")}`,
|
|
831
|
+
filesUploaded: uploadResult.uploaded
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
if (infraResult.distributionId && uploadResult.uploaded > 0) {
|
|
835
|
+
onProgress?.("invalidate", "Invalidating CloudFront cache...");
|
|
836
|
+
const { invalidateCache } = await import("./chunk-d2p5n2aq.js");
|
|
837
|
+
await invalidateCache(infraResult.distributionId);
|
|
838
|
+
}
|
|
839
|
+
onProgress?.("complete", "Deployment complete!");
|
|
840
|
+
const message = uploadResult.skipped > 0 ? `Deployed ${uploadResult.uploaded} files (${uploadResult.skipped} unchanged) with external DNS` : `Deployed ${uploadResult.uploaded} files successfully with external DNS`;
|
|
841
|
+
return {
|
|
842
|
+
...infraResult,
|
|
843
|
+
filesUploaded: uploadResult.uploaded,
|
|
844
|
+
filesSkipped: uploadResult.skipped,
|
|
845
|
+
message
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/deploy/static-site.ts
|
|
850
|
+
function generateStaticSiteTemplate(config) {
|
|
851
|
+
const {
|
|
852
|
+
bucketName,
|
|
853
|
+
domain,
|
|
854
|
+
certificateArn,
|
|
855
|
+
hostedZoneId,
|
|
856
|
+
defaultRootObject = "index.html",
|
|
857
|
+
errorDocument = "404.html"
|
|
858
|
+
} = config;
|
|
859
|
+
const resources = {};
|
|
860
|
+
const outputs = {};
|
|
861
|
+
resources.S3Bucket = {
|
|
862
|
+
Type: "AWS::S3::Bucket",
|
|
863
|
+
Properties: {
|
|
864
|
+
BucketName: bucketName,
|
|
865
|
+
PublicAccessBlockConfiguration: {
|
|
866
|
+
BlockPublicAcls: true,
|
|
867
|
+
BlockPublicPolicy: false,
|
|
868
|
+
IgnorePublicAcls: true,
|
|
869
|
+
RestrictPublicBuckets: false
|
|
870
|
+
},
|
|
871
|
+
WebsiteConfiguration: {
|
|
872
|
+
IndexDocument: defaultRootObject,
|
|
873
|
+
ErrorDocument: errorDocument
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
outputs.BucketName = {
|
|
878
|
+
Description: "S3 Bucket Name",
|
|
879
|
+
Value: { Ref: "S3Bucket" }
|
|
880
|
+
};
|
|
881
|
+
outputs.BucketArn = {
|
|
882
|
+
Description: "S3 Bucket ARN",
|
|
883
|
+
Value: { "Fn::GetAtt": ["S3Bucket", "Arn"] }
|
|
884
|
+
};
|
|
885
|
+
resources.CloudFrontOAC = {
|
|
886
|
+
Type: "AWS::CloudFront::OriginAccessControl",
|
|
887
|
+
Properties: {
|
|
888
|
+
OriginAccessControlConfig: {
|
|
889
|
+
Name: `OAC-${bucketName}`,
|
|
890
|
+
Description: `OAC for ${bucketName}`,
|
|
891
|
+
OriginAccessControlOriginType: "s3",
|
|
892
|
+
SigningBehavior: "always",
|
|
893
|
+
SigningProtocol: "sigv4"
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
resources.UrlRewriteFunction = {
|
|
898
|
+
Type: "AWS::CloudFront::Function",
|
|
899
|
+
Properties: {
|
|
900
|
+
Name: { "Fn::Sub": "${AWS::StackName}-url-rewrite" },
|
|
901
|
+
AutoPublish: true,
|
|
902
|
+
FunctionConfig: {
|
|
903
|
+
Comment: "Append .html extension to URLs without extensions",
|
|
904
|
+
Runtime: "cloudfront-js-2.0"
|
|
905
|
+
},
|
|
906
|
+
FunctionCode: `function handler(event) {
|
|
907
|
+
const request = event.request;
|
|
908
|
+
var uri = request.uri;
|
|
909
|
+
|
|
910
|
+
// If URI ends with /, serve index.html
|
|
911
|
+
if (uri.endsWith('/')) {
|
|
912
|
+
request.uri = uri + 'index.html';
|
|
913
|
+
}
|
|
914
|
+
// If URI doesn't have an extension, append .html
|
|
915
|
+
else if (!uri.includes('.')) {
|
|
916
|
+
request.uri = uri + '.html';
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return request;
|
|
920
|
+
}`
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
const distributionConfig = {
|
|
924
|
+
Enabled: true,
|
|
925
|
+
DefaultRootObject: defaultRootObject,
|
|
926
|
+
HttpVersion: "http2and3",
|
|
927
|
+
IPV6Enabled: true,
|
|
928
|
+
PriceClass: "PriceClass_100",
|
|
929
|
+
Origins: [
|
|
930
|
+
{
|
|
931
|
+
Id: `S3-${bucketName}`,
|
|
932
|
+
DomainName: { "Fn::GetAtt": ["S3Bucket", "RegionalDomainName"] },
|
|
933
|
+
S3OriginConfig: {
|
|
934
|
+
OriginAccessIdentity: ""
|
|
935
|
+
},
|
|
936
|
+
OriginAccessControlId: { "Fn::GetAtt": ["CloudFrontOAC", "Id"] }
|
|
937
|
+
}
|
|
938
|
+
],
|
|
939
|
+
DefaultCacheBehavior: {
|
|
940
|
+
TargetOriginId: `S3-${bucketName}`,
|
|
941
|
+
ViewerProtocolPolicy: "redirect-to-https",
|
|
942
|
+
AllowedMethods: ["GET", "HEAD"],
|
|
943
|
+
CachedMethods: ["GET", "HEAD"],
|
|
944
|
+
Compress: true,
|
|
945
|
+
CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6",
|
|
946
|
+
FunctionAssociations: [
|
|
947
|
+
{
|
|
948
|
+
EventType: "viewer-request",
|
|
949
|
+
FunctionARN: { "Fn::GetAtt": ["UrlRewriteFunction", "FunctionARN"] }
|
|
950
|
+
}
|
|
951
|
+
]
|
|
952
|
+
},
|
|
953
|
+
CustomErrorResponses: [
|
|
954
|
+
{
|
|
955
|
+
ErrorCode: 403,
|
|
956
|
+
ResponseCode: 200,
|
|
957
|
+
ResponsePagePath: `/${defaultRootObject}`,
|
|
958
|
+
ErrorCachingMinTTL: 300
|
|
959
|
+
},
|
|
960
|
+
{
|
|
961
|
+
ErrorCode: 404,
|
|
962
|
+
ResponseCode: 404,
|
|
963
|
+
ResponsePagePath: `/${errorDocument}`,
|
|
964
|
+
ErrorCachingMinTTL: 300
|
|
965
|
+
}
|
|
966
|
+
]
|
|
967
|
+
};
|
|
968
|
+
if (domain && certificateArn) {
|
|
969
|
+
distributionConfig.Aliases = [domain];
|
|
970
|
+
distributionConfig.ViewerCertificate = {
|
|
971
|
+
AcmCertificateArn: certificateArn,
|
|
972
|
+
SslSupportMethod: "sni-only",
|
|
973
|
+
MinimumProtocolVersion: "TLSv1.2_2021"
|
|
974
|
+
};
|
|
975
|
+
} else {
|
|
976
|
+
distributionConfig.ViewerCertificate = {
|
|
977
|
+
CloudFrontDefaultCertificate: true
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
resources.CloudFrontDistribution = {
|
|
981
|
+
Type: "AWS::CloudFront::Distribution",
|
|
982
|
+
DependsOn: ["S3Bucket", "CloudFrontOAC", "UrlRewriteFunction"],
|
|
983
|
+
Properties: {
|
|
984
|
+
DistributionConfig: distributionConfig
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
outputs.DistributionId = {
|
|
988
|
+
Description: "CloudFront Distribution ID",
|
|
989
|
+
Value: { Ref: "CloudFrontDistribution" }
|
|
990
|
+
};
|
|
991
|
+
outputs.DistributionDomain = {
|
|
992
|
+
Description: "CloudFront Distribution Domain",
|
|
993
|
+
Value: { "Fn::GetAtt": ["CloudFrontDistribution", "DomainName"] }
|
|
994
|
+
};
|
|
995
|
+
resources.S3BucketPolicy = {
|
|
996
|
+
Type: "AWS::S3::BucketPolicy",
|
|
997
|
+
DependsOn: ["S3Bucket", "CloudFrontDistribution"],
|
|
998
|
+
Properties: {
|
|
999
|
+
Bucket: { Ref: "S3Bucket" },
|
|
1000
|
+
PolicyDocument: {
|
|
1001
|
+
Version: "2012-10-17",
|
|
1002
|
+
Statement: [
|
|
1003
|
+
{
|
|
1004
|
+
Sid: "AllowCloudFrontServicePrincipal",
|
|
1005
|
+
Effect: "Allow",
|
|
1006
|
+
Principal: {
|
|
1007
|
+
Service: "cloudfront.amazonaws.com"
|
|
1008
|
+
},
|
|
1009
|
+
Action: "s3:GetObject",
|
|
1010
|
+
Resource: { "Fn::Sub": "arn:aws:s3:::${S3Bucket}/*" },
|
|
1011
|
+
Condition: {
|
|
1012
|
+
StringEquals: {
|
|
1013
|
+
"AWS:SourceArn": {
|
|
1014
|
+
"Fn::Sub": "arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}"
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
]
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
if (domain && hostedZoneId) {
|
|
1024
|
+
resources.DNSRecord = {
|
|
1025
|
+
Type: "AWS::Route53::RecordSet",
|
|
1026
|
+
DependsOn: "CloudFrontDistribution",
|
|
1027
|
+
Properties: {
|
|
1028
|
+
HostedZoneId: hostedZoneId,
|
|
1029
|
+
Name: domain,
|
|
1030
|
+
Type: "A",
|
|
1031
|
+
AliasTarget: {
|
|
1032
|
+
DNSName: { "Fn::GetAtt": ["CloudFrontDistribution", "DomainName"] },
|
|
1033
|
+
HostedZoneId: "Z2FDTNDATAQYW2",
|
|
1034
|
+
EvaluateTargetHealth: false
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
resources.DNSRecordIPv6 = {
|
|
1039
|
+
Type: "AWS::Route53::RecordSet",
|
|
1040
|
+
DependsOn: "CloudFrontDistribution",
|
|
1041
|
+
Properties: {
|
|
1042
|
+
HostedZoneId: hostedZoneId,
|
|
1043
|
+
Name: domain,
|
|
1044
|
+
Type: "AAAA",
|
|
1045
|
+
AliasTarget: {
|
|
1046
|
+
DNSName: { "Fn::GetAtt": ["CloudFrontDistribution", "DomainName"] },
|
|
1047
|
+
HostedZoneId: "Z2FDTNDATAQYW2",
|
|
1048
|
+
EvaluateTargetHealth: false
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
outputs.SiteUrl = {
|
|
1053
|
+
Description: "Site URL",
|
|
1054
|
+
Value: { "Fn::Sub": "https://${DNSRecord}" }
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
return {
|
|
1058
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
1059
|
+
Description: `Static site infrastructure for ${domain || bucketName}`,
|
|
1060
|
+
Resources: resources,
|
|
1061
|
+
Outputs: outputs
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
async function deployStaticSite(config) {
|
|
1065
|
+
if (config.dnsProvider && config.dnsProvider.provider !== "route53") {
|
|
1066
|
+
const domain2 = config.domain || (config.subdomain && config.baseDomain ? `${config.subdomain}.${config.baseDomain}` : undefined);
|
|
1067
|
+
if (!domain2) {
|
|
1068
|
+
return {
|
|
1069
|
+
success: false,
|
|
1070
|
+
stackName: config.stackName || `${config.siteName}-static-site`,
|
|
1071
|
+
bucket: config.bucket || `${config.siteName}-${Date.now()}`,
|
|
1072
|
+
message: "Domain is required when using external DNS provider"
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
return deployStaticSiteWithExternalDns({
|
|
1076
|
+
siteName: config.siteName,
|
|
1077
|
+
region: config.region,
|
|
1078
|
+
domain: domain2,
|
|
1079
|
+
bucket: config.bucket,
|
|
1080
|
+
certificateArn: config.certificateArn,
|
|
1081
|
+
stackName: config.stackName,
|
|
1082
|
+
defaultRootObject: config.defaultRootObject,
|
|
1083
|
+
errorDocument: config.errorDocument,
|
|
1084
|
+
cacheControl: config.cacheControl,
|
|
1085
|
+
tags: config.tags,
|
|
1086
|
+
dnsProvider: config.dnsProvider
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
const region = config.region || "us-east-1";
|
|
1090
|
+
const cfRegion = "us-east-1";
|
|
1091
|
+
let domain;
|
|
1092
|
+
if (config.domain) {
|
|
1093
|
+
domain = config.domain;
|
|
1094
|
+
} else if (config.subdomain && config.baseDomain) {
|
|
1095
|
+
domain = `${config.subdomain}.${config.baseDomain}`;
|
|
1096
|
+
}
|
|
1097
|
+
const bucket = config.bucket || (domain ? domain.replace(/\./g, "-") : `${config.siteName}-${Date.now()}`);
|
|
1098
|
+
const stackName = config.stackName || `${config.siteName}-static-site`;
|
|
1099
|
+
const cf = new CloudFormationClient(cfRegion);
|
|
1100
|
+
const route53 = new Route53Client;
|
|
1101
|
+
const acm = new ACMClient("us-east-1");
|
|
1102
|
+
const acmValidator = new ACMDnsValidator("us-east-1");
|
|
1103
|
+
let hostedZoneId = config.hostedZoneId;
|
|
1104
|
+
let certificateArn = config.certificateArn;
|
|
1105
|
+
if (domain && !hostedZoneId) {
|
|
1106
|
+
const zone = await route53.findHostedZoneForDomain(domain);
|
|
1107
|
+
if (zone) {
|
|
1108
|
+
hostedZoneId = zone.Id.replace("/hostedzone/", "");
|
|
1109
|
+
} else {
|
|
1110
|
+
return {
|
|
1111
|
+
success: false,
|
|
1112
|
+
stackName,
|
|
1113
|
+
bucket,
|
|
1114
|
+
message: `No Route53 hosted zone found for ${config.baseDomain || domain}. Please create one first.`
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
if (domain && !certificateArn && hostedZoneId) {
|
|
1119
|
+
const existingCert = await acm.findCertificateByDomain(domain);
|
|
1120
|
+
if (existingCert && existingCert.Status === "ISSUED") {
|
|
1121
|
+
certificateArn = existingCert.CertificateArn;
|
|
1122
|
+
} else {
|
|
1123
|
+
const certResult = await acmValidator.requestAndValidate({
|
|
1124
|
+
domainName: domain,
|
|
1125
|
+
hostedZoneId,
|
|
1126
|
+
waitForValidation: true,
|
|
1127
|
+
maxWaitMinutes: 10
|
|
1128
|
+
});
|
|
1129
|
+
certificateArn = certResult.certificateArn;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
let stackExists = false;
|
|
1133
|
+
let existingBucketName;
|
|
1134
|
+
try {
|
|
1135
|
+
const existingStacks = await cf.describeStacks({ stackName });
|
|
1136
|
+
if (existingStacks.Stacks.length > 0) {
|
|
1137
|
+
const stack = existingStacks.Stacks[0];
|
|
1138
|
+
const stackStatus = stack.StackStatus;
|
|
1139
|
+
if (stackStatus === "DELETE_IN_PROGRESS") {
|
|
1140
|
+
console.log("Previous stack is still being deleted, waiting...");
|
|
1141
|
+
await cf.waitForStack(stackName, "stack-delete-complete");
|
|
1142
|
+
stackExists = false;
|
|
1143
|
+
} else if (stackStatus === "DELETE_COMPLETE") {
|
|
1144
|
+
stackExists = false;
|
|
1145
|
+
} else {
|
|
1146
|
+
stackExists = true;
|
|
1147
|
+
const outputs2 = stack.Outputs || [];
|
|
1148
|
+
existingBucketName = outputs2.find((o) => o.OutputKey === "BucketName")?.OutputValue;
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
if (err.message?.includes("does not exist") || err.code === "ValidationError") {
|
|
1153
|
+
stackExists = false;
|
|
1154
|
+
} else {
|
|
1155
|
+
throw err;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
let finalBucket = existingBucketName || bucket;
|
|
1159
|
+
if (!stackExists) {
|
|
1160
|
+
const s3 = new S3Client(region);
|
|
1161
|
+
const cloudfront = new CloudFrontClient;
|
|
1162
|
+
let hasExistingDistribution = false;
|
|
1163
|
+
if (domain) {
|
|
1164
|
+
try {
|
|
1165
|
+
console.log(`Checking for existing CloudFront distribution for ${domain}...`);
|
|
1166
|
+
const distributions = await cloudfront.listDistributions();
|
|
1167
|
+
for (const dist of distributions) {
|
|
1168
|
+
let aliases = [];
|
|
1169
|
+
if (dist.Aliases?.Items) {
|
|
1170
|
+
if (Array.isArray(dist.Aliases.Items)) {
|
|
1171
|
+
aliases = dist.Aliases.Items;
|
|
1172
|
+
} else if (typeof dist.Aliases.Items === "object") {
|
|
1173
|
+
const cname = dist.Aliases.Items.CNAME;
|
|
1174
|
+
if (typeof cname === "string") {
|
|
1175
|
+
aliases = [cname];
|
|
1176
|
+
} else if (Array.isArray(cname)) {
|
|
1177
|
+
aliases = cname;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
if (aliases.includes(domain)) {
|
|
1182
|
+
hasExistingDistribution = true;
|
|
1183
|
+
console.log(`Found existing CloudFront distribution ${dist.Id} for ${domain}`);
|
|
1184
|
+
const distConfig = await cloudfront.getDistributionConfig(dist.Id);
|
|
1185
|
+
const originsData = distConfig.DistributionConfig?.Origins?.Items;
|
|
1186
|
+
let originBucket;
|
|
1187
|
+
if (originsData) {
|
|
1188
|
+
let originList = [];
|
|
1189
|
+
if (Array.isArray(originsData)) {
|
|
1190
|
+
originList = originsData;
|
|
1191
|
+
} else if (originsData.Origin) {
|
|
1192
|
+
originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin];
|
|
1193
|
+
} else {
|
|
1194
|
+
originList = [originsData];
|
|
1195
|
+
}
|
|
1196
|
+
for (const origin of originList) {
|
|
1197
|
+
const domainName = origin.DomainName || "";
|
|
1198
|
+
const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/);
|
|
1199
|
+
if (s3Match) {
|
|
1200
|
+
originBucket = s3Match[1];
|
|
1201
|
+
break;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
if (originBucket) {
|
|
1206
|
+
const expectedBucketPrefix = domain.replace(/\./g, "-");
|
|
1207
|
+
if (!originBucket.startsWith(expectedBucketPrefix) && !originBucket.includes(config.siteName)) {
|
|
1208
|
+
console.log(`Warning: Found distribution with mismatched bucket ${originBucket}, skipping...`);
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
console.log(`Using existing S3 bucket: ${originBucket}`);
|
|
1212
|
+
if (hostedZoneId && dist.DomainName) {
|
|
1213
|
+
try {
|
|
1214
|
+
console.log(`Ensuring Route53 records exist for ${domain}...`);
|
|
1215
|
+
await route53.createAliasRecord({
|
|
1216
|
+
HostedZoneId: hostedZoneId,
|
|
1217
|
+
Name: domain,
|
|
1218
|
+
Type: "A",
|
|
1219
|
+
TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
|
|
1220
|
+
TargetDNSName: dist.DomainName,
|
|
1221
|
+
EvaluateTargetHealth: false
|
|
1222
|
+
});
|
|
1223
|
+
await route53.createAliasRecord({
|
|
1224
|
+
HostedZoneId: hostedZoneId,
|
|
1225
|
+
Name: domain,
|
|
1226
|
+
Type: "AAAA",
|
|
1227
|
+
TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
|
|
1228
|
+
TargetDNSName: dist.DomainName,
|
|
1229
|
+
EvaluateTargetHealth: false
|
|
1230
|
+
});
|
|
1231
|
+
console.log(`Route53 records ensured for ${domain}`);
|
|
1232
|
+
} catch (dnsErr) {
|
|
1233
|
+
console.log(`Note: Could not update Route53 records: ${dnsErr.message}`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
return {
|
|
1237
|
+
success: true,
|
|
1238
|
+
stackName: `existing-${dist.Id}`,
|
|
1239
|
+
bucket: originBucket,
|
|
1240
|
+
distributionId: dist.Id,
|
|
1241
|
+
distributionDomain: dist.DomainName,
|
|
1242
|
+
domain,
|
|
1243
|
+
certificateArn,
|
|
1244
|
+
message: "Using existing CloudFront distribution"
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
} catch {}
|
|
1250
|
+
if (hostedZoneId) {
|
|
1251
|
+
try {
|
|
1252
|
+
const recordsResult = await route53.listResourceRecordSets({ HostedZoneId: hostedZoneId });
|
|
1253
|
+
const records = recordsResult.ResourceRecordSets || [];
|
|
1254
|
+
for (const record of records) {
|
|
1255
|
+
if (record.Name === `${domain}.` && (record.Type === "A" || record.Type === "AAAA")) {
|
|
1256
|
+
if (record.AliasTarget) {
|
|
1257
|
+
console.log(`Found orphaned Route53 ${record.Type} record for ${domain}, cleaning up...`);
|
|
1258
|
+
try {
|
|
1259
|
+
await route53.deleteRecord({
|
|
1260
|
+
HostedZoneId: hostedZoneId,
|
|
1261
|
+
RecordSet: record
|
|
1262
|
+
});
|
|
1263
|
+
console.log(`Deleted orphaned Route53 ${record.Type} record for ${domain}`);
|
|
1264
|
+
} catch (recordErr) {
|
|
1265
|
+
console.log(`Note: Could not delete Route53 record: ${recordErr.message}`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
} catch {}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
if (!hasExistingDistribution) {
|
|
1274
|
+
try {
|
|
1275
|
+
const headResult = await s3.headBucket(bucket);
|
|
1276
|
+
if (headResult.exists) {
|
|
1277
|
+
console.log(`Found orphaned S3 bucket ${bucket}, cleaning up...`);
|
|
1278
|
+
try {
|
|
1279
|
+
const cleanupPromise = s3.emptyBucket(bucket).then(() => s3.deleteBucket(bucket));
|
|
1280
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Bucket cleanup timeout")), 30000));
|
|
1281
|
+
await Promise.race([cleanupPromise, timeoutPromise]);
|
|
1282
|
+
console.log(`Deleted orphaned S3 bucket ${bucket}`);
|
|
1283
|
+
} catch (cleanupErr) {
|
|
1284
|
+
console.log(`Note: Could not clean up S3 bucket: ${cleanupErr.message}`);
|
|
1285
|
+
const suffix = Date.now().toString(36);
|
|
1286
|
+
finalBucket = `${bucket}-${suffix}`;
|
|
1287
|
+
console.log(`Using alternative bucket name: ${finalBucket}`);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
} catch {}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
const template = generateStaticSiteTemplate({
|
|
1294
|
+
bucketName: finalBucket,
|
|
1295
|
+
domain,
|
|
1296
|
+
certificateArn,
|
|
1297
|
+
hostedZoneId,
|
|
1298
|
+
defaultRootObject: config.defaultRootObject,
|
|
1299
|
+
errorDocument: config.errorDocument
|
|
1300
|
+
});
|
|
1301
|
+
const tags = Object.entries(config.tags || {}).map(([Key, Value]) => ({ Key, Value }));
|
|
1302
|
+
tags.push({ Key: "ManagedBy", Value: "ts-cloud" });
|
|
1303
|
+
tags.push({ Key: "Application", Value: config.siteName });
|
|
1304
|
+
let stackId;
|
|
1305
|
+
let isUpdate = false;
|
|
1306
|
+
if (stackExists) {
|
|
1307
|
+
isUpdate = true;
|
|
1308
|
+
console.log(`Updating CloudFormation stack: ${stackName}`);
|
|
1309
|
+
console.log(`Using existing bucket: ${finalBucket}`);
|
|
1310
|
+
console.log(`Domain: ${domain || "not specified"}`);
|
|
1311
|
+
console.log(`Certificate ARN: ${certificateArn || "not specified"}`);
|
|
1312
|
+
try {
|
|
1313
|
+
const result = await cf.updateStack({
|
|
1314
|
+
stackName,
|
|
1315
|
+
templateBody: JSON.stringify(template),
|
|
1316
|
+
capabilities: ["CAPABILITY_IAM"],
|
|
1317
|
+
tags
|
|
1318
|
+
});
|
|
1319
|
+
stackId = result.StackId;
|
|
1320
|
+
console.log(`Update initiated, stack ID: ${stackId}`);
|
|
1321
|
+
} catch (err) {
|
|
1322
|
+
if (err.message?.includes("No updates are to be performed")) {
|
|
1323
|
+
const stacks2 = await cf.describeStacks({ stackName });
|
|
1324
|
+
stackId = stacks2.Stacks[0].StackId;
|
|
1325
|
+
const outputs2 = stacks2.Stacks[0]?.Outputs || [];
|
|
1326
|
+
const getOutput2 = (key) => outputs2.find((o) => o.OutputKey === key)?.OutputValue;
|
|
1327
|
+
return {
|
|
1328
|
+
success: true,
|
|
1329
|
+
stackId,
|
|
1330
|
+
stackName,
|
|
1331
|
+
bucket: getOutput2("BucketName") || finalBucket,
|
|
1332
|
+
distributionId: getOutput2("DistributionId"),
|
|
1333
|
+
distributionDomain: getOutput2("DistributionDomain"),
|
|
1334
|
+
domain,
|
|
1335
|
+
certificateArn,
|
|
1336
|
+
message: "Static site infrastructure is already up to date"
|
|
1337
|
+
};
|
|
1338
|
+
} else {
|
|
1339
|
+
throw err;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
} else {
|
|
1343
|
+
console.log(`Creating CloudFormation stack: ${stackName}`);
|
|
1344
|
+
console.log(`Bucket name: ${finalBucket}`);
|
|
1345
|
+
console.log(`Domain: ${domain || "not specified"}`);
|
|
1346
|
+
console.log(`Certificate ARN: ${certificateArn || "not specified"}`);
|
|
1347
|
+
console.log("Stack does not exist, creating...");
|
|
1348
|
+
const result = await cf.createStack({
|
|
1349
|
+
stackName,
|
|
1350
|
+
templateBody: JSON.stringify(template),
|
|
1351
|
+
capabilities: ["CAPABILITY_IAM"],
|
|
1352
|
+
tags,
|
|
1353
|
+
onFailure: "DELETE"
|
|
1354
|
+
});
|
|
1355
|
+
stackId = result.StackId;
|
|
1356
|
+
console.log(`Create initiated, stack ID: ${stackId}`);
|
|
1357
|
+
}
|
|
1358
|
+
console.log(`Waiting for stack to reach ${isUpdate ? "stack-update-complete" : "stack-create-complete"}...`);
|
|
1359
|
+
try {
|
|
1360
|
+
await cf.waitForStack(stackName, isUpdate ? "stack-update-complete" : "stack-create-complete");
|
|
1361
|
+
console.log("Stack operation completed successfully!");
|
|
1362
|
+
} catch (err) {
|
|
1363
|
+
if (err.message?.includes("must be verified") || err.message?.includes("Access denied for operation") || err.message?.includes("failed")) {
|
|
1364
|
+
console.log("CloudFormation deployment failed, trying direct API creation...");
|
|
1365
|
+
const cloudfront = new CloudFrontClient;
|
|
1366
|
+
if (domain) {
|
|
1367
|
+
try {
|
|
1368
|
+
const distributions = await cloudfront.listDistributions();
|
|
1369
|
+
for (const dist of distributions) {
|
|
1370
|
+
let aliases = [];
|
|
1371
|
+
if (dist.Aliases?.Items) {
|
|
1372
|
+
if (Array.isArray(dist.Aliases.Items)) {
|
|
1373
|
+
aliases = dist.Aliases.Items;
|
|
1374
|
+
} else if (typeof dist.Aliases.Items === "object") {
|
|
1375
|
+
const cname = dist.Aliases.Items.CNAME;
|
|
1376
|
+
if (typeof cname === "string") {
|
|
1377
|
+
aliases = [cname];
|
|
1378
|
+
} else if (Array.isArray(cname)) {
|
|
1379
|
+
aliases = cname;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
if (aliases.includes(domain)) {
|
|
1384
|
+
console.log(`Found existing CloudFront distribution ${dist.Id} with alias ${domain}`);
|
|
1385
|
+
const distConfig = await cloudfront.getDistributionConfig(dist.Id);
|
|
1386
|
+
const originsData = distConfig.DistributionConfig?.Origins?.Items;
|
|
1387
|
+
let originBucket;
|
|
1388
|
+
if (originsData) {
|
|
1389
|
+
let originList = [];
|
|
1390
|
+
if (Array.isArray(originsData)) {
|
|
1391
|
+
originList = originsData;
|
|
1392
|
+
} else if (originsData.Origin) {
|
|
1393
|
+
originList = Array.isArray(originsData.Origin) ? originsData.Origin : [originsData.Origin];
|
|
1394
|
+
} else {
|
|
1395
|
+
originList = [originsData];
|
|
1396
|
+
}
|
|
1397
|
+
for (const origin of originList) {
|
|
1398
|
+
const domainName = origin.DomainName || "";
|
|
1399
|
+
const s3Match = domainName.match(/^([^.]+)\.s3[\.-]/);
|
|
1400
|
+
if (s3Match) {
|
|
1401
|
+
originBucket = s3Match[1];
|
|
1402
|
+
break;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
if (originBucket) {
|
|
1407
|
+
console.log(`Using existing S3 bucket: ${originBucket}`);
|
|
1408
|
+
return {
|
|
1409
|
+
success: true,
|
|
1410
|
+
stackName: `existing-${dist.Id}`,
|
|
1411
|
+
bucket: originBucket,
|
|
1412
|
+
distributionId: dist.Id,
|
|
1413
|
+
distributionDomain: dist.DomainName,
|
|
1414
|
+
domain,
|
|
1415
|
+
certificateArn,
|
|
1416
|
+
message: "Using existing CloudFront distribution (account verification pending for new distributions)"
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
} catch {}
|
|
1422
|
+
}
|
|
1423
|
+
console.log("No existing infrastructure found, creating via direct API calls...");
|
|
1424
|
+
try {
|
|
1425
|
+
const s3Direct = new S3Client(region);
|
|
1426
|
+
const bucketExists = await s3Direct.headBucket(finalBucket);
|
|
1427
|
+
if (bucketExists.exists) {
|
|
1428
|
+
console.log(`Using existing S3 bucket: ${finalBucket}`);
|
|
1429
|
+
} else {
|
|
1430
|
+
console.log(`Creating S3 bucket: ${finalBucket}...`);
|
|
1431
|
+
await s3Direct.createBucket(finalBucket);
|
|
1432
|
+
}
|
|
1433
|
+
await s3Direct.putBucketWebsite(finalBucket, {
|
|
1434
|
+
IndexDocument: config.defaultRootObject || "index.html",
|
|
1435
|
+
ErrorDocument: config.errorDocument || "404.html"
|
|
1436
|
+
});
|
|
1437
|
+
await s3Direct.putPublicAccessBlock(finalBucket, {
|
|
1438
|
+
BlockPublicAcls: true,
|
|
1439
|
+
IgnorePublicAcls: true,
|
|
1440
|
+
BlockPublicPolicy: false,
|
|
1441
|
+
RestrictPublicBuckets: false
|
|
1442
|
+
});
|
|
1443
|
+
console.log(`S3 bucket ${finalBucket} configured`);
|
|
1444
|
+
const oacName = `OAC-${finalBucket}`;
|
|
1445
|
+
console.log(`Creating Origin Access Control: ${oacName}...`);
|
|
1446
|
+
const oac = await cloudfront.findOrCreateOriginAccessControl(oacName);
|
|
1447
|
+
console.log(`Origin Access Control ${oac.Id} ready`);
|
|
1448
|
+
console.log(`Creating CloudFront distribution...`);
|
|
1449
|
+
const distResult = await cloudfront.createDistributionForS3({
|
|
1450
|
+
bucketName: finalBucket,
|
|
1451
|
+
bucketRegion: region,
|
|
1452
|
+
originAccessControlId: oac.Id,
|
|
1453
|
+
aliases: domain ? [domain] : [],
|
|
1454
|
+
certificateArn,
|
|
1455
|
+
defaultRootObject: config.defaultRootObject || "index.html",
|
|
1456
|
+
comment: `Distribution for ${domain || finalBucket}`
|
|
1457
|
+
});
|
|
1458
|
+
console.log(`CloudFront distribution ${distResult.Id} created`);
|
|
1459
|
+
console.log(`Updating S3 bucket policy...`);
|
|
1460
|
+
const bucketPolicy = CloudFrontClient.getS3BucketPolicyForCloudFront(finalBucket, distResult.ARN);
|
|
1461
|
+
await s3Direct.putBucketPolicy(finalBucket, bucketPolicy);
|
|
1462
|
+
console.log(`S3 bucket policy updated`);
|
|
1463
|
+
if (domain && hostedZoneId) {
|
|
1464
|
+
console.log(`Creating Route53 records for ${domain}...`);
|
|
1465
|
+
try {
|
|
1466
|
+
await route53.createAliasRecord({
|
|
1467
|
+
HostedZoneId: hostedZoneId,
|
|
1468
|
+
Name: domain,
|
|
1469
|
+
Type: "A",
|
|
1470
|
+
TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
|
|
1471
|
+
TargetDNSName: distResult.DomainName,
|
|
1472
|
+
EvaluateTargetHealth: false
|
|
1473
|
+
});
|
|
1474
|
+
await route53.createAliasRecord({
|
|
1475
|
+
HostedZoneId: hostedZoneId,
|
|
1476
|
+
Name: domain,
|
|
1477
|
+
Type: "AAAA",
|
|
1478
|
+
TargetHostedZoneId: Route53Client.CloudFrontHostedZoneId,
|
|
1479
|
+
TargetDNSName: distResult.DomainName,
|
|
1480
|
+
EvaluateTargetHealth: false
|
|
1481
|
+
});
|
|
1482
|
+
console.log(`Route53 records created for ${domain}`);
|
|
1483
|
+
} catch (dnsErr) {
|
|
1484
|
+
console.log(`Note: Could not create Route53 records: ${dnsErr.message}`);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return {
|
|
1488
|
+
success: true,
|
|
1489
|
+
stackName: `direct-${distResult.Id}`,
|
|
1490
|
+
bucket: finalBucket,
|
|
1491
|
+
distributionId: distResult.Id,
|
|
1492
|
+
distributionDomain: distResult.DomainName,
|
|
1493
|
+
domain,
|
|
1494
|
+
certificateArn,
|
|
1495
|
+
message: "Static site infrastructure created via direct API calls"
|
|
1496
|
+
};
|
|
1497
|
+
} catch (directErr) {
|
|
1498
|
+
console.log(`Direct API creation failed: ${directErr.message}`);
|
|
1499
|
+
return {
|
|
1500
|
+
success: false,
|
|
1501
|
+
stackId,
|
|
1502
|
+
stackName,
|
|
1503
|
+
bucket: finalBucket,
|
|
1504
|
+
message: `Deployment failed: ${directErr.message}`
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
return {
|
|
1509
|
+
success: false,
|
|
1510
|
+
stackId,
|
|
1511
|
+
stackName,
|
|
1512
|
+
bucket: finalBucket,
|
|
1513
|
+
message: `Stack deployment failed: ${err.message}`
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
const stacks = await cf.describeStacks({ stackName });
|
|
1517
|
+
const outputs = stacks.Stacks[0]?.Outputs || [];
|
|
1518
|
+
const getOutput = (key) => outputs.find((o) => o.OutputKey === key)?.OutputValue;
|
|
1519
|
+
return {
|
|
1520
|
+
success: true,
|
|
1521
|
+
stackId,
|
|
1522
|
+
stackName,
|
|
1523
|
+
bucket: getOutput("BucketName") || finalBucket,
|
|
1524
|
+
distributionId: getOutput("DistributionId"),
|
|
1525
|
+
distributionDomain: getOutput("DistributionDomain"),
|
|
1526
|
+
domain,
|
|
1527
|
+
certificateArn,
|
|
1528
|
+
message: "Static site infrastructure deployed successfully"
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
async function uploadStaticFiles(options) {
|
|
1532
|
+
const { sourceDir, bucket, region, cacheControl = "max-age=31536000, public", onProgress } = options;
|
|
1533
|
+
const s3 = new S3Client(region);
|
|
1534
|
+
const { readdir } = await import("node:fs/promises");
|
|
1535
|
+
const { join, relative } = await import("node:path");
|
|
1536
|
+
const { createHash } = await import("node:crypto");
|
|
1537
|
+
async function listFiles(dir) {
|
|
1538
|
+
const files2 = [];
|
|
1539
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1540
|
+
for (const entry of entries) {
|
|
1541
|
+
const fullPath = join(dir, entry.name);
|
|
1542
|
+
if (entry.isDirectory()) {
|
|
1543
|
+
files2.push(...await listFiles(fullPath));
|
|
1544
|
+
} else {
|
|
1545
|
+
files2.push(fullPath);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return files2;
|
|
1549
|
+
}
|
|
1550
|
+
function getContentType(filePath) {
|
|
1551
|
+
const ext = filePath.split(".").pop()?.toLowerCase();
|
|
1552
|
+
const types = {
|
|
1553
|
+
html: "text/html; charset=utf-8",
|
|
1554
|
+
css: "text/css; charset=utf-8",
|
|
1555
|
+
js: "application/javascript; charset=utf-8",
|
|
1556
|
+
json: "application/json; charset=utf-8",
|
|
1557
|
+
png: "image/png",
|
|
1558
|
+
jpg: "image/jpeg",
|
|
1559
|
+
jpeg: "image/jpeg",
|
|
1560
|
+
gif: "image/gif",
|
|
1561
|
+
svg: "image/svg+xml",
|
|
1562
|
+
ico: "image/x-icon",
|
|
1563
|
+
webp: "image/webp",
|
|
1564
|
+
woff: "font/woff",
|
|
1565
|
+
woff2: "font/woff2",
|
|
1566
|
+
ttf: "font/ttf",
|
|
1567
|
+
xml: "application/xml",
|
|
1568
|
+
txt: "text/plain; charset=utf-8",
|
|
1569
|
+
sh: "text/plain; charset=utf-8",
|
|
1570
|
+
bash: "text/plain; charset=utf-8",
|
|
1571
|
+
ps1: "text/plain; charset=utf-8"
|
|
1572
|
+
};
|
|
1573
|
+
return types[ext || ""] || "application/octet-stream";
|
|
1574
|
+
}
|
|
1575
|
+
function computeMD5(content) {
|
|
1576
|
+
return createHash("md5").update(content).digest("hex");
|
|
1577
|
+
}
|
|
1578
|
+
async function getExistingETags() {
|
|
1579
|
+
const etagMap = new Map;
|
|
1580
|
+
let continuationToken;
|
|
1581
|
+
do {
|
|
1582
|
+
const result = await s3.listObjects({
|
|
1583
|
+
bucket,
|
|
1584
|
+
maxKeys: 1000,
|
|
1585
|
+
continuationToken
|
|
1586
|
+
});
|
|
1587
|
+
for (const obj of result.objects) {
|
|
1588
|
+
const etag = obj.ETag?.replace(/"/g, "") || "";
|
|
1589
|
+
etagMap.set(obj.Key, etag);
|
|
1590
|
+
}
|
|
1591
|
+
continuationToken = result.nextContinuationToken;
|
|
1592
|
+
} while (continuationToken);
|
|
1593
|
+
return etagMap;
|
|
1594
|
+
}
|
|
1595
|
+
const files = await listFiles(sourceDir);
|
|
1596
|
+
const errors = [];
|
|
1597
|
+
let uploaded = 0;
|
|
1598
|
+
let skipped = 0;
|
|
1599
|
+
const existingETags = await getExistingETags();
|
|
1600
|
+
for (const file of files) {
|
|
1601
|
+
const key = relative(sourceDir, file);
|
|
1602
|
+
const contentType = getContentType(file);
|
|
1603
|
+
const fileCacheControl = file.endsWith(".html") ? "max-age=3600, public" : cacheControl;
|
|
1604
|
+
try {
|
|
1605
|
+
const content = Buffer.from(await Bun.file(file).arrayBuffer());
|
|
1606
|
+
const localMD5 = computeMD5(content);
|
|
1607
|
+
const existingETag = existingETags.get(key);
|
|
1608
|
+
if (existingETag && existingETag === localMD5) {
|
|
1609
|
+
skipped++;
|
|
1610
|
+
onProgress?.(uploaded + skipped, files.length, key);
|
|
1611
|
+
continue;
|
|
1612
|
+
}
|
|
1613
|
+
await s3.putObject({
|
|
1614
|
+
bucket,
|
|
1615
|
+
key,
|
|
1616
|
+
body: content,
|
|
1617
|
+
contentType,
|
|
1618
|
+
cacheControl: fileCacheControl
|
|
1619
|
+
});
|
|
1620
|
+
uploaded++;
|
|
1621
|
+
onProgress?.(uploaded + skipped, files.length, key);
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
errors.push(`Failed to upload ${key}: ${err.message}`);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
return { uploaded, skipped, errors };
|
|
1627
|
+
}
|
|
1628
|
+
async function invalidateCache(distributionId) {
|
|
1629
|
+
const cloudfront = new CloudFrontClient;
|
|
1630
|
+
const result = await cloudfront.invalidateAll(distributionId);
|
|
1631
|
+
return { invalidationId: result.Id };
|
|
1632
|
+
}
|
|
1633
|
+
async function deleteStaticSite(stackName, region = "us-east-1") {
|
|
1634
|
+
const cf = new CloudFormationClient(region);
|
|
1635
|
+
try {
|
|
1636
|
+
const stacks = await cf.describeStacks({ stackName });
|
|
1637
|
+
const outputs = stacks.Stacks[0]?.Outputs || [];
|
|
1638
|
+
const bucketName = outputs.find((o) => o.OutputKey === "BucketName")?.OutputValue;
|
|
1639
|
+
if (bucketName) {
|
|
1640
|
+
const s3 = new S3Client(region);
|
|
1641
|
+
await s3.emptyBucket(bucketName);
|
|
1642
|
+
}
|
|
1643
|
+
} catch {}
|
|
1644
|
+
await cf.deleteStack(stackName);
|
|
1645
|
+
const result = await cf.waitForStackComplete(stackName, 60, 1e4);
|
|
1646
|
+
return {
|
|
1647
|
+
success: result.success || result.status === "DELETE_COMPLETE",
|
|
1648
|
+
message: result.success ? "Static site deleted successfully" : `Deletion failed: ${result.status}`
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
async function deployStaticSiteFull(config) {
|
|
1652
|
+
const { sourceDir, cleanBucket = false, onProgress, ...siteConfig } = config;
|
|
1653
|
+
onProgress?.("infrastructure", "Deploying CloudFormation stack...");
|
|
1654
|
+
const infraResult = await deployStaticSite(siteConfig);
|
|
1655
|
+
if (!infraResult.success) {
|
|
1656
|
+
return infraResult;
|
|
1657
|
+
}
|
|
1658
|
+
if (cleanBucket) {
|
|
1659
|
+
onProgress?.("clean", "Cleaning old files from S3...");
|
|
1660
|
+
try {
|
|
1661
|
+
const s3 = new S3Client(siteConfig.region || "us-east-1");
|
|
1662
|
+
await s3.emptyBucket(infraResult.bucket);
|
|
1663
|
+
} catch (err) {
|
|
1664
|
+
console.log(`Note: Could not clean bucket: ${err.message}`);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
onProgress?.("upload", "Uploading files to S3...");
|
|
1668
|
+
const uploadResult = await uploadStaticFiles({
|
|
1669
|
+
sourceDir,
|
|
1670
|
+
bucket: infraResult.bucket,
|
|
1671
|
+
region: siteConfig.region || "us-east-1",
|
|
1672
|
+
cacheControl: siteConfig.cacheControl,
|
|
1673
|
+
onProgress: (uploaded, total, file) => {
|
|
1674
|
+
onProgress?.("upload", `${uploaded}/${total}: ${file}`);
|
|
1675
|
+
}
|
|
1676
|
+
});
|
|
1677
|
+
if (uploadResult.errors.length > 0) {
|
|
1678
|
+
return {
|
|
1679
|
+
...infraResult,
|
|
1680
|
+
success: false,
|
|
1681
|
+
message: `Upload errors: ${uploadResult.errors.join(", ")}`,
|
|
1682
|
+
filesUploaded: uploadResult.uploaded
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
if (infraResult.distributionId && uploadResult.uploaded > 0) {
|
|
1686
|
+
onProgress?.("invalidate", "Invalidating CloudFront cache...");
|
|
1687
|
+
await invalidateCache(infraResult.distributionId);
|
|
1688
|
+
}
|
|
1689
|
+
onProgress?.("complete", "Deployment complete!");
|
|
1690
|
+
const message = uploadResult.skipped > 0 ? `Deployed ${uploadResult.uploaded} files (${uploadResult.skipped} unchanged)` : `Deployed ${uploadResult.uploaded} files successfully`;
|
|
1691
|
+
return {
|
|
1692
|
+
...infraResult,
|
|
1693
|
+
filesUploaded: uploadResult.uploaded,
|
|
1694
|
+
filesSkipped: uploadResult.skipped,
|
|
1695
|
+
message
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
export { generateExternalDnsStaticSiteTemplate, deployStaticSiteWithExternalDns, deployStaticSiteWithExternalDnsFull, generateStaticSiteTemplate, deployStaticSite, uploadStaticFiles, invalidateCache, deleteStaticSite, deployStaticSiteFull };
|