Skip to content

Commit 4923394

Browse files
committed
chore: wip
1 parent 2e63bbb commit 4923394

2 files changed

Lines changed: 124 additions & 10 deletions

File tree

packages/ts-cloud/src/aws/s3.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,9 @@ export class S3Client {
466466
${keys.map(key => `<Object><Key>${key}</Key></Object>`).join('\n ')}
467467
</Delete>`
468468

469+
// S3 DeleteObjects requires Content-MD5 header
470+
const contentMd5 = crypto.createHash('md5').update(deleteXml).digest('base64')
471+
469472
await this.client.request({
470473
service: 's3',
471474
region: this.region,
@@ -475,6 +478,7 @@ export class S3Client {
475478
body: deleteXml,
476479
headers: {
477480
'Content-Type': 'application/xml',
481+
'Content-MD5': contentMd5,
478482
},
479483
})
480484
}
@@ -2034,6 +2038,59 @@ export class S3Client {
20342038
return presignedUrl
20352039
}
20362040

2041+
/**
2042+
* List objects in a bucket with pagination support
2043+
*/
2044+
async listObjects(options: {
2045+
bucket: string
2046+
prefix?: string
2047+
maxKeys?: number
2048+
continuationToken?: string
2049+
}): Promise<{
2050+
objects: S3Object[]
2051+
nextContinuationToken?: string
2052+
}> {
2053+
const { bucket, prefix, maxKeys = 1000, continuationToken } = options
2054+
2055+
// Build query parameters for ListObjectsV2
2056+
const queryParams: Record<string, string> = {
2057+
'list-type': '2',
2058+
'max-keys': maxKeys.toString(),
2059+
}
2060+
2061+
if (prefix) queryParams.prefix = prefix
2062+
if (continuationToken) queryParams['continuation-token'] = continuationToken
2063+
2064+
const result = await this.client.request({
2065+
service: 's3',
2066+
region: this.region,
2067+
method: 'GET',
2068+
path: `/${bucket}`,
2069+
queryParams,
2070+
})
2071+
2072+
// Parse S3 XML response
2073+
const objects: S3Object[] = []
2074+
const listResult = result?.ListBucketResult
2075+
2076+
if (listResult?.Contents) {
2077+
const items = Array.isArray(listResult.Contents) ? listResult.Contents : [listResult.Contents]
2078+
for (const item of items) {
2079+
objects.push({
2080+
Key: item.Key || '',
2081+
LastModified: item.LastModified || '',
2082+
Size: Number.parseInt(item.Size || '0'),
2083+
ETag: item.ETag,
2084+
})
2085+
}
2086+
}
2087+
2088+
return {
2089+
objects,
2090+
nextContinuationToken: listResult?.NextContinuationToken,
2091+
}
2092+
}
2093+
20372094
/**
20382095
* Empty a bucket by deleting all objects (required before bucket deletion)
20392096
*/

packages/ts-cloud/src/deploy/static-site.ts

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,34 @@ export function generateStaticSiteTemplate(config: {
128128
},
129129
}
130130

131+
// CloudFront Function for URL rewriting (append .html to URLs without extensions)
132+
resources.UrlRewriteFunction = {
133+
Type: 'AWS::CloudFront::Function',
134+
Properties: {
135+
Name: { 'Fn::Sub': '${AWS::StackName}-url-rewrite' },
136+
AutoPublish: true,
137+
FunctionConfig: {
138+
Comment: 'Append .html extension to URLs without extensions',
139+
Runtime: 'cloudfront-js-2.0',
140+
},
141+
FunctionCode: `function handler(event) {
142+
var request = event.request;
143+
var uri = request.uri;
144+
145+
// If URI ends with /, serve index.html
146+
if (uri.endsWith('/')) {
147+
request.uri = uri + 'index.html';
148+
}
149+
// If URI doesn't have an extension, append .html
150+
else if (!uri.includes('.')) {
151+
request.uri = uri + '.html';
152+
}
153+
154+
return request;
155+
}`,
156+
},
157+
}
158+
131159
// CloudFront Distribution
132160
const distributionConfig: any = {
133161
Enabled: true,
@@ -152,6 +180,12 @@ export function generateStaticSiteTemplate(config: {
152180
CachedMethods: ['GET', 'HEAD'],
153181
Compress: true,
154182
CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6', // Managed-CachingOptimized
183+
FunctionAssociations: [
184+
{
185+
EventType: 'viewer-request',
186+
FunctionARN: { 'Fn::GetAtt': ['UrlRewriteFunction', 'FunctionARN'] },
187+
},
188+
],
155189
},
156190
CustomErrorResponses: [
157191
{
@@ -186,7 +220,7 @@ export function generateStaticSiteTemplate(config: {
186220

187221
resources.CloudFrontDistribution = {
188222
Type: 'AWS::CloudFront::Distribution',
189-
DependsOn: ['S3Bucket', 'CloudFrontOAC'],
223+
DependsOn: ['S3Bucket', 'CloudFrontOAC', 'UrlRewriteFunction'],
190224
Properties: {
191225
DistributionConfig: distributionConfig,
192226
},
@@ -347,10 +381,12 @@ export async function deployStaticSite(config: StaticSiteConfig): Promise<Deploy
347381

348382
// Check if stack already exists
349383
let stackExists = false
384+
let existingBucketName: string | undefined
350385
try {
351386
const existingStacks = await cf.describeStacks({ stackName })
352387
if (existingStacks.Stacks.length > 0) {
353-
const stackStatus = existingStacks.Stacks[0].StackStatus
388+
const stack = existingStacks.Stacks[0]
389+
const stackStatus = stack.StackStatus
354390

355391
// If stack is being deleted, wait for it to complete
356392
if (stackStatus === 'DELETE_IN_PROGRESS') {
@@ -363,6 +399,9 @@ export async function deployStaticSite(config: StaticSiteConfig): Promise<Deploy
363399
}
364400
else {
365401
stackExists = true
402+
// Get existing bucket name from stack outputs to ensure consistency during updates
403+
const outputs = stack.Outputs || []
404+
existingBucketName = outputs.find(o => o.OutputKey === 'BucketName')?.OutputValue
366405
}
367406
}
368407
}
@@ -378,7 +417,8 @@ export async function deployStaticSite(config: StaticSiteConfig): Promise<Deploy
378417

379418
// If stack doesn't exist, check for orphaned resources and clean them up
380419
// Use a unique bucket name suffix if cleanup fails
381-
let finalBucket = bucket
420+
// If stack exists, use the existing bucket name to avoid CloudFormation trying to recreate resources
421+
let finalBucket = existingBucketName || bucket
382422
if (!stackExists) {
383423
const s3 = new S3Client(region)
384424
const cloudfront = new CloudFrontClient()
@@ -513,14 +553,13 @@ export async function deployStaticSite(config: StaticSiteConfig): Promise<Deploy
513553
// Create or update stack
514554
let stackId: string
515555
let isUpdate = false
516-
console.log(`Creating CloudFormation stack: ${stackName}`)
517-
console.log(`Bucket name: ${finalBucket}`)
518-
console.log(`Domain: ${domain || 'not specified'}`)
519-
console.log(`Certificate ARN: ${certificateArn || 'not specified'}`)
520556

521557
if (stackExists) {
522558
isUpdate = true
523-
console.log('Stack exists, updating...')
559+
console.log(`Updating CloudFormation stack: ${stackName}`)
560+
console.log(`Using existing bucket: ${finalBucket}`)
561+
console.log(`Domain: ${domain || 'not specified'}`)
562+
console.log(`Certificate ARN: ${certificateArn || 'not specified'}`)
524563
try {
525564
const result = await cf.updateStack({
526565
stackName,
@@ -558,6 +597,10 @@ export async function deployStaticSite(config: StaticSiteConfig): Promise<Deploy
558597
}
559598
}
560599
else {
600+
console.log(`Creating CloudFormation stack: ${stackName}`)
601+
console.log(`Bucket name: ${finalBucket}`)
602+
console.log(`Domain: ${domain || 'not specified'}`)
603+
console.log(`Certificate ARN: ${certificateArn || 'not specified'}`)
561604
console.log('Stack does not exist, creating...')
562605
const result = await cf.createStack({
563606
stackName,
@@ -734,9 +777,10 @@ export async function deleteStaticSite(stackName: string, region: string = 'us-e
734777
*/
735778
export async function deployStaticSiteFull(config: StaticSiteConfig & {
736779
sourceDir: string
780+
cleanBucket?: boolean
737781
onProgress?: (stage: string, detail?: string) => void
738782
}): Promise<DeployResult & { filesUploaded?: number }> {
739-
const { sourceDir, onProgress, ...siteConfig } = config
783+
const { sourceDir, cleanBucket = true, onProgress, ...siteConfig } = config
740784

741785
// Step 1: Deploy infrastructure
742786
onProgress?.('infrastructure', 'Deploying CloudFormation stack...')
@@ -746,7 +790,20 @@ export async function deployStaticSiteFull(config: StaticSiteConfig & {
746790
return infraResult
747791
}
748792

749-
// Step 2: Upload files
793+
// Step 2: Clean bucket before upload (ensures no stale files)
794+
if (cleanBucket) {
795+
onProgress?.('clean', 'Cleaning old files from S3...')
796+
try {
797+
const s3 = new S3Client(siteConfig.region || 'us-east-1')
798+
await s3.emptyBucket(infraResult.bucket)
799+
}
800+
catch (err: any) {
801+
// Log but don't fail - bucket might be empty
802+
console.log(`Note: Could not clean bucket: ${err.message}`)
803+
}
804+
}
805+
806+
// Step 3: Upload files
750807
onProgress?.('upload', 'Uploading files to S3...')
751808
const uploadResult = await uploadStaticFiles({
752809
sourceDir,

0 commit comments

Comments
 (0)