@stacksjs/ts-cloud 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/aws/s3.d.ts +1 -1
  2. package/dist/bin/cli.js +223 -222
  3. package/dist/index.js +132 -132
  4. package/package.json +18 -16
  5. package/src/aws/acm.ts +768 -0
  6. package/src/aws/application-autoscaling.ts +845 -0
  7. package/src/aws/bedrock.ts +4074 -0
  8. package/src/aws/client.ts +891 -0
  9. package/src/aws/cloudformation.ts +896 -0
  10. package/src/aws/cloudfront.ts +1531 -0
  11. package/src/aws/cloudwatch-logs.ts +154 -0
  12. package/src/aws/comprehend.ts +839 -0
  13. package/src/aws/connect.ts +1056 -0
  14. package/src/aws/deploy-imap.ts +384 -0
  15. package/src/aws/dynamodb.ts +340 -0
  16. package/src/aws/ec2.ts +1385 -0
  17. package/src/aws/ecr.ts +621 -0
  18. package/src/aws/ecs.ts +615 -0
  19. package/src/aws/elasticache.ts +301 -0
  20. package/src/aws/elbv2.ts +942 -0
  21. package/src/aws/email.ts +928 -0
  22. package/src/aws/eventbridge.ts +248 -0
  23. package/src/aws/iam.ts +1689 -0
  24. package/src/aws/imap-server.ts +2100 -0
  25. package/src/aws/index.ts +213 -0
  26. package/src/aws/kendra.ts +1097 -0
  27. package/src/aws/lambda.ts +786 -0
  28. package/src/aws/opensearch.ts +158 -0
  29. package/src/aws/personalize.ts +977 -0
  30. package/src/aws/polly.ts +559 -0
  31. package/src/aws/rds.ts +888 -0
  32. package/src/aws/rekognition.ts +846 -0
  33. package/src/aws/route53-domains.ts +359 -0
  34. package/src/aws/route53.ts +1046 -0
  35. package/src/aws/s3.ts +2334 -0
  36. package/src/aws/scheduler.ts +571 -0
  37. package/src/aws/secrets-manager.ts +769 -0
  38. package/src/aws/ses.ts +1081 -0
  39. package/src/aws/setup-phone.ts +104 -0
  40. package/src/aws/setup-sms.ts +580 -0
  41. package/src/aws/sms.ts +1735 -0
  42. package/src/aws/smtp-server.ts +531 -0
  43. package/src/aws/sns.ts +758 -0
  44. package/src/aws/sqs.ts +382 -0
  45. package/src/aws/ssm.ts +807 -0
  46. package/src/aws/sts.ts +92 -0
  47. package/src/aws/support.ts +391 -0
  48. package/src/aws/test-imap.ts +86 -0
  49. package/src/aws/textract.ts +780 -0
  50. package/src/aws/transcribe.ts +108 -0
  51. package/src/aws/translate.ts +641 -0
  52. package/src/aws/voice.ts +1379 -0
  53. package/src/config.ts +35 -0
  54. package/src/deploy/index.ts +7 -0
  55. package/src/deploy/static-site-external-dns.ts +945 -0
  56. package/src/deploy/static-site.ts +1175 -0
  57. package/src/dns/cloudflare.ts +548 -0
  58. package/src/dns/godaddy.ts +412 -0
  59. package/src/dns/index.ts +205 -0
  60. package/src/dns/porkbun.ts +362 -0
  61. package/src/dns/route53-adapter.ts +414 -0
  62. package/src/dns/types.ts +119 -0
  63. package/src/dns/validator.ts +369 -0
  64. package/src/generators/index.ts +5 -0
  65. package/src/generators/infrastructure.ts +1660 -0
  66. package/src/index.ts +163 -0
  67. package/src/push/apns.ts +452 -0
  68. package/src/push/fcm.ts +506 -0
  69. package/src/push/index.ts +58 -0
  70. package/src/security/pre-deploy-scanner.ts +655 -0
  71. package/src/ssl/acme-client.ts +478 -0
  72. package/src/ssl/index.ts +7 -0
  73. package/src/ssl/letsencrypt.ts +747 -0
  74. package/src/types.ts +2 -0
  75. package/src/utils/cli.ts +398 -0
  76. package/src/validation/index.ts +5 -0
  77. package/src/validation/template.ts +405 -0
@@ -0,0 +1,891 @@
1
+ /**
2
+ * AWS API Client - Direct API calls without AWS CLI
3
+ * Implements AWS Signature Version 4 for authentication
4
+ */
5
+
6
+ import * as crypto from 'node:crypto'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { XMLParser } from 'fast-xml-parser'
11
+
12
+ export interface AWSCredentials {
13
+ accessKeyId: string
14
+ secretAccessKey: string
15
+ sessionToken?: string
16
+ }
17
+
18
+ export interface AWSRequestOptions {
19
+ service: string
20
+ region: string
21
+ method: string
22
+ path: string
23
+ queryParams?: Record<string, string>
24
+ headers?: Record<string, string>
25
+ body?: string
26
+ credentials?: AWSCredentials
27
+ retries?: number
28
+ cacheKey?: string
29
+ cacheTTL?: number
30
+ returnHeaders?: boolean
31
+ rawResponse?: boolean
32
+ /** S3 bucket name for virtual-hosted style URLs */
33
+ bucket?: string
34
+ }
35
+
36
+ export interface AWSClientConfig {
37
+ maxRetries?: number
38
+ retryDelay?: number
39
+ cacheEnabled?: boolean
40
+ defaultCacheTTL?: number
41
+ }
42
+
43
+ export interface AWSError extends Error {
44
+ code?: string
45
+ statusCode?: number
46
+ requestId?: string
47
+ type?: string
48
+ }
49
+
50
+ interface CacheEntry {
51
+ data: any
52
+ expires: number
53
+ }
54
+
55
+ /**
56
+ * AWS API Client - Makes authenticated requests to AWS services
57
+ */
58
+ export class AWSClient {
59
+ private credentials?: AWSCredentials
60
+ private config: AWSClientConfig
61
+ private cache: Map<string, CacheEntry>
62
+ private xmlParser: XMLParser
63
+
64
+ constructor(credentials?: AWSCredentials, config?: AWSClientConfig) {
65
+ this.credentials = credentials || this.loadCredentials()
66
+ this.config = {
67
+ maxRetries: 3,
68
+ retryDelay: 1000,
69
+ cacheEnabled: true,
70
+ defaultCacheTTL: 60000, // 1 minute
71
+ ...config,
72
+ }
73
+ this.cache = new Map()
74
+ this.xmlParser = new XMLParser({
75
+ ignoreAttributes: false,
76
+ attributeNamePrefix: '@_',
77
+ textNodeName: '#text',
78
+ parseAttributeValue: true,
79
+ trimValues: true,
80
+ })
81
+ }
82
+
83
+ /**
84
+ * Load AWS credentials from environment variables, credentials file, or EC2 instance metadata
85
+ */
86
+ private loadCredentials(): AWSCredentials {
87
+ // 1. Check environment variables first
88
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID
89
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY
90
+ const sessionToken = process.env.AWS_SESSION_TOKEN
91
+
92
+ if (accessKeyId && secretAccessKey) {
93
+ return {
94
+ accessKeyId,
95
+ secretAccessKey,
96
+ sessionToken,
97
+ }
98
+ }
99
+
100
+ // 2. Try to load from ~/.aws/credentials file
101
+ const fileCredentials = this.loadCredentialsFromFile()
102
+ if (fileCredentials) {
103
+ return fileCredentials
104
+ }
105
+
106
+ // Return placeholder - will be loaded async from EC2 metadata
107
+ // The actual credentials will be fetched in getCredentials()
108
+ return {
109
+ accessKeyId: '',
110
+ secretAccessKey: '',
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Load credentials from ~/.aws/credentials file
116
+ */
117
+ private loadCredentialsFromFile(): AWSCredentials | null {
118
+ const profile = process.env.AWS_PROFILE || 'default'
119
+ const credentialsPath = process.env.AWS_SHARED_CREDENTIALS_FILE || join(homedir(), '.aws', 'credentials')
120
+
121
+ if (!existsSync(credentialsPath)) {
122
+ return null
123
+ }
124
+
125
+ try {
126
+ const content = readFileSync(credentialsPath, 'utf-8')
127
+ const credentials = this.parseCredentialsFile(content, profile)
128
+ if (credentials.accessKeyId && credentials.secretAccessKey) {
129
+ return credentials
130
+ }
131
+ }
132
+ catch {
133
+ // Failed to read or parse credentials file
134
+ }
135
+
136
+ return null
137
+ }
138
+
139
+ /**
140
+ * Parse AWS credentials file (INI format)
141
+ */
142
+ private parseCredentialsFile(content: string, profile: string): AWSCredentials {
143
+ const lines = content.split('\n')
144
+ let currentProfile = ''
145
+ let accessKeyId = ''
146
+ let secretAccessKey = ''
147
+ let sessionToken: string | undefined
148
+
149
+ for (const line of lines) {
150
+ const trimmed = line.trim()
151
+
152
+ // Skip empty lines and comments
153
+ if (!trimmed || trimmed.startsWith('#')) {
154
+ continue
155
+ }
156
+
157
+ // Check for profile header [profile-name]
158
+ const profileMatch = trimmed.match(/^\[([^\]]+)\]$/)
159
+ if (profileMatch) {
160
+ currentProfile = profileMatch[1]
161
+ continue
162
+ }
163
+
164
+ // Parse key=value pairs for the target profile
165
+ if (currentProfile === profile) {
166
+ const [key, ...valueParts] = trimmed.split('=')
167
+ const value = valueParts.join('=').trim()
168
+
169
+ if (key.trim() === 'aws_access_key_id') {
170
+ accessKeyId = value
171
+ }
172
+ else if (key.trim() === 'aws_secret_access_key') {
173
+ secretAccessKey = value
174
+ }
175
+ else if (key.trim() === 'aws_session_token') {
176
+ sessionToken = value
177
+ }
178
+ }
179
+ }
180
+
181
+ return { accessKeyId, secretAccessKey, sessionToken }
182
+ }
183
+
184
+ /**
185
+ * Cache for EC2 instance metadata credentials
186
+ */
187
+ private ec2CredentialsCache?: {
188
+ credentials: AWSCredentials
189
+ expiration: number
190
+ }
191
+
192
+ /**
193
+ * Get credentials, fetching from credentials file or EC2 metadata if needed
194
+ */
195
+ private async getCredentials(): Promise<AWSCredentials> {
196
+ // 1. Check environment variables first
197
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID
198
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY
199
+ if (accessKeyId && secretAccessKey) {
200
+ return {
201
+ accessKeyId,
202
+ secretAccessKey,
203
+ sessionToken: process.env.AWS_SESSION_TOKEN,
204
+ }
205
+ }
206
+
207
+ // 2. Try to load from ~/.aws/credentials file
208
+ const fileCredentials = this.loadCredentialsFromFile()
209
+ if (fileCredentials) {
210
+ return fileCredentials
211
+ }
212
+
213
+ // 3. Check if we have cached EC2 credentials that haven't expired
214
+ if (this.ec2CredentialsCache) {
215
+ const now = Date.now()
216
+ // Refresh 5 minutes before expiration
217
+ if (this.ec2CredentialsCache.expiration > now + 5 * 60 * 1000) {
218
+ return this.ec2CredentialsCache.credentials
219
+ }
220
+ }
221
+
222
+ // Fetch from EC2 instance metadata service (IMDSv2)
223
+ try {
224
+ // Get token for IMDSv2
225
+ const tokenResponse = await fetch('http://169.254.169.254/latest/api/token', {
226
+ method: 'PUT',
227
+ headers: {
228
+ 'X-aws-ec2-metadata-token-ttl-seconds': '21600',
229
+ },
230
+ })
231
+ const token = await tokenResponse.text()
232
+
233
+ // Get IAM role name
234
+ const roleResponse = await fetch('http://169.254.169.254/latest/meta-data/iam/security-credentials/', {
235
+ headers: { 'X-aws-ec2-metadata-token': token },
236
+ })
237
+ const roleName = await roleResponse.text()
238
+
239
+ // Get credentials for the role
240
+ const credsResponse = await fetch(`http://169.254.169.254/latest/meta-data/iam/security-credentials/${roleName}`, {
241
+ headers: { 'X-aws-ec2-metadata-token': token },
242
+ })
243
+ const credsData = await credsResponse.json() as {
244
+ AccessKeyId: string
245
+ SecretAccessKey: string
246
+ Token: string
247
+ Expiration: string
248
+ }
249
+
250
+ const credentials: AWSCredentials = {
251
+ accessKeyId: credsData.AccessKeyId,
252
+ secretAccessKey: credsData.SecretAccessKey,
253
+ sessionToken: credsData.Token,
254
+ }
255
+
256
+ // Cache the credentials
257
+ this.ec2CredentialsCache = {
258
+ credentials,
259
+ expiration: new Date(credsData.Expiration).getTime(),
260
+ }
261
+
262
+ return credentials
263
+ }
264
+ catch (error) {
265
+ throw new Error('AWS credentials not found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, or run on an EC2 instance with an IAM role.')
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Make a signed AWS API request with retry logic and caching
271
+ */
272
+ async request(options: AWSRequestOptions): Promise<any> {
273
+ // Check cache first for GET requests
274
+ if (options.method === 'GET' && this.config.cacheEnabled && options.cacheKey) {
275
+ const cached = this.getFromCache(options.cacheKey)
276
+ if (cached !== null) {
277
+ return cached
278
+ }
279
+ }
280
+
281
+ const maxRetries = options.retries ?? this.config.maxRetries ?? 3
282
+ let lastError: Error | null = null
283
+
284
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
285
+ try {
286
+ const result = await this.makeRequest(options)
287
+
288
+ // Cache successful GET requests
289
+ if (options.method === 'GET' && this.config.cacheEnabled && options.cacheKey) {
290
+ this.setInCache(
291
+ options.cacheKey,
292
+ result,
293
+ options.cacheTTL ?? this.config.defaultCacheTTL ?? 60000,
294
+ )
295
+ }
296
+
297
+ return result
298
+ }
299
+ catch (error: any) {
300
+ lastError = error
301
+
302
+ // Check if error is retryable
303
+ const isRetryable = this.shouldRetry(error)
304
+
305
+ // Don't retry non-retryable errors
306
+ if (!isRetryable) {
307
+ throw error
308
+ }
309
+
310
+ // Last attempt
311
+ if (attempt === maxRetries) {
312
+ throw error
313
+ }
314
+
315
+ // Wait before retrying with exponential backoff
316
+ const delay = this.calculateRetryDelay(attempt)
317
+ await this.sleep(delay)
318
+ }
319
+ }
320
+
321
+ throw lastError || new Error('Request failed after retries')
322
+ }
323
+
324
+ /**
325
+ * Make the actual HTTP request
326
+ */
327
+ private async makeRequest(options: AWSRequestOptions): Promise<any> {
328
+ const credentials = options.credentials || await this.getCredentials()
329
+ if (!credentials || !credentials.accessKeyId || !credentials.secretAccessKey) {
330
+ throw new Error('AWS credentials not provided')
331
+ }
332
+
333
+ const url = this.buildUrl(options)
334
+ const headers = this.signRequest(options, credentials)
335
+
336
+ const response = await fetch(url, {
337
+ method: options.method,
338
+ headers,
339
+ body: options.body,
340
+ })
341
+
342
+ const responseText = await response.text()
343
+
344
+ if (!response.ok) {
345
+ throw this.parseError(responseText, response.status, response.headers)
346
+ }
347
+
348
+ // Handle empty responses
349
+ if (!responseText || responseText.trim() === '') {
350
+ if (options.returnHeaders) {
351
+ return { body: null, headers: this.headersToObject(response.headers) }
352
+ }
353
+ return null
354
+ }
355
+
356
+ // Return raw response if requested (useful for S3 getObject with non-XML content)
357
+ if (options.rawResponse) {
358
+ if (options.returnHeaders) {
359
+ return { body: responseText, headers: this.headersToObject(response.headers) }
360
+ }
361
+ return responseText
362
+ }
363
+
364
+ // Parse XML or JSON response
365
+ let body: any
366
+ if (responseText.startsWith('<')) {
367
+ body = this.parseXmlResponse(responseText)
368
+ }
369
+ else {
370
+ try {
371
+ body = JSON.parse(responseText)
372
+ }
373
+ catch {
374
+ body = responseText
375
+ }
376
+ }
377
+
378
+ // Return with headers if requested
379
+ if (options.returnHeaders) {
380
+ return { body, headers: this.headersToObject(response.headers) }
381
+ }
382
+
383
+ return body
384
+ }
385
+
386
+ /**
387
+ * Convert Headers object to plain object
388
+ */
389
+ private headersToObject(headers: Headers): Record<string, string> {
390
+ const result: Record<string, string> = {}
391
+ headers.forEach((value, key) => {
392
+ result[key] = value
393
+ })
394
+ return result
395
+ }
396
+
397
+ /**
398
+ * Build the full URL for the request
399
+ */
400
+ private buildUrl(options: AWSRequestOptions): string {
401
+ const { service, region, path, queryParams } = options
402
+
403
+ let host: string
404
+ if (service === 's3') {
405
+ // Use virtual-hosted style for S3 buckets (required for newer buckets)
406
+ if (options.bucket) {
407
+ host = `${options.bucket}.s3.${region}.amazonaws.com`
408
+ }
409
+ else {
410
+ host = `s3.${region}.amazonaws.com`
411
+ }
412
+ }
413
+ else if (service === 'cloudfront') {
414
+ host = 'cloudfront.amazonaws.com'
415
+ }
416
+ else if (service === 'iam') {
417
+ // IAM is a global service - no region in the endpoint
418
+ host = 'iam.amazonaws.com'
419
+ }
420
+ else if (service === 'route53') {
421
+ // Route53 is a global service
422
+ host = 'route53.amazonaws.com'
423
+ }
424
+ else if (service === 'route53domains') {
425
+ // Route53 Domains is only available in us-east-1
426
+ host = 'route53domains.us-east-1.amazonaws.com'
427
+ }
428
+ else if (service === 'ecr') {
429
+ // ECR uses api.ecr subdomain for the JSON API
430
+ host = `api.ecr.${region}.amazonaws.com`
431
+ }
432
+ else if (service === 'ses') {
433
+ // SES v1 API uses email subdomain
434
+ host = `email.${region}.amazonaws.com`
435
+ }
436
+ else {
437
+ host = `${service}.${region}.amazonaws.com`
438
+ }
439
+
440
+ let url = `https://${host}${path}`
441
+
442
+ if (queryParams && Object.keys(queryParams).length > 0) {
443
+ const queryString = Object.keys(queryParams)
444
+ .map(k => `${this.uriEncode(k)}=${this.uriEncode(queryParams[k])}`)
445
+ .join('&')
446
+ url += `?${queryString}`
447
+ }
448
+
449
+ return url
450
+ }
451
+
452
+ /**
453
+ * Sign the request using AWS Signature Version 4
454
+ */
455
+ private signRequest(options: AWSRequestOptions, credentials: AWSCredentials): Record<string, string> {
456
+ const { service, region, method, path, queryParams, body } = options
457
+
458
+ const now = new Date()
459
+ const amzDate = this.getAmzDate(now)
460
+ const dateStamp = this.getDateStamp(now)
461
+
462
+ let host: string
463
+ if (service === 's3') {
464
+ if (options.bucket) {
465
+ host = `${options.bucket}.s3.${region}.amazonaws.com`
466
+ } else {
467
+ host = `s3.${region}.amazonaws.com`
468
+ }
469
+ }
470
+ else if (service === 'cloudfront') {
471
+ host = 'cloudfront.amazonaws.com'
472
+ }
473
+ else if (service === 'iam') {
474
+ // IAM is a global service - no region in the endpoint
475
+ host = 'iam.amazonaws.com'
476
+ }
477
+ else if (service === 'route53') {
478
+ // Route53 is a global service
479
+ host = 'route53.amazonaws.com'
480
+ }
481
+ else if (service === 'route53domains') {
482
+ // Route53 Domains is only available in us-east-1
483
+ host = 'route53domains.us-east-1.amazonaws.com'
484
+ }
485
+ else if (service === 'ecr') {
486
+ // ECR uses api.ecr subdomain for the JSON API
487
+ host = `api.ecr.${region}.amazonaws.com`
488
+ }
489
+ else if (service === 'ses') {
490
+ // SES v1 API uses email subdomain
491
+ host = `email.${region}.amazonaws.com`
492
+ }
493
+ else {
494
+ host = `${service}.${region}.amazonaws.com`
495
+ }
496
+
497
+ // Build canonical headers
498
+ const headers: Record<string, string> = {
499
+ 'host': host,
500
+ 'x-amz-date': amzDate,
501
+ ...(options.headers || {}),
502
+ }
503
+
504
+ if (credentials.sessionToken) {
505
+ headers['x-amz-security-token'] = credentials.sessionToken
506
+ }
507
+
508
+ if (body) {
509
+ // Don't override content-type if already set (case-insensitive check)
510
+ const hasContentType = Object.keys(headers).some(
511
+ k => k.toLowerCase() === 'content-type'
512
+ )
513
+ if (!hasContentType) {
514
+ headers['content-type'] = 'application/x-www-form-urlencoded'
515
+ }
516
+ headers['content-length'] = Buffer.byteLength(body).toString()
517
+ }
518
+
519
+ // Create canonical request
520
+ const payloadHash = this.sha256(body || '')
521
+ headers['x-amz-content-sha256'] = payloadHash
522
+
523
+ const canonicalUri = path
524
+ const canonicalQueryString = queryParams
525
+ ? Object.keys(queryParams).sort().map(k =>
526
+ `${this.uriEncode(k)}=${this.uriEncode(queryParams[k])}`
527
+ ).join('&')
528
+ : ''
529
+
530
+ // Sort headers by lowercase key name (AWS SigV4 requirement)
531
+ const sortedHeaderKeys = Object.keys(headers).sort((a, b) =>
532
+ a.toLowerCase().localeCompare(b.toLowerCase())
533
+ )
534
+
535
+ const canonicalHeaders = sortedHeaderKeys
536
+ .map(key => `${key.toLowerCase()}:${headers[key].trim()}\n`)
537
+ .join('')
538
+
539
+ const signedHeaders = sortedHeaderKeys
540
+ .map(key => key.toLowerCase())
541
+ .join(';')
542
+
543
+ const canonicalRequest = [
544
+ method,
545
+ canonicalUri,
546
+ canonicalQueryString,
547
+ canonicalHeaders,
548
+ signedHeaders,
549
+ payloadHash,
550
+ ].join('\n')
551
+
552
+ // Create string to sign
553
+ const algorithm = 'AWS4-HMAC-SHA256'
554
+ // Some AWS services use different service names for endpoint vs credential scope
555
+ // SES v2 API uses 'email' as endpoint but 'ses' for credential scope
556
+ let signingService = service
557
+ if (service === 'email') signingService = 'ses'
558
+ const credentialScope = `${dateStamp}/${region}/${signingService}/aws4_request`
559
+ const stringToSign = [
560
+ algorithm,
561
+ amzDate,
562
+ credentialScope,
563
+ this.sha256(canonicalRequest),
564
+ ].join('\n')
565
+
566
+ // Calculate signature
567
+ const signingKey = this.getSignatureKey(credentials.secretAccessKey, dateStamp, region, signingService)
568
+ const signature = this.hmac(signingKey, stringToSign)
569
+
570
+ // Build authorization header
571
+ const authorizationHeader = `${algorithm} Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
572
+
573
+ return {
574
+ ...headers,
575
+ 'Authorization': authorizationHeader,
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Get AMZ date format (YYYYMMDDTHHMMSSZ)
581
+ */
582
+ private getAmzDate(date: Date): string {
583
+ return date.toISOString().replace(/[:-]|\.\d{3}/g, '')
584
+ }
585
+
586
+ /**
587
+ * Get date stamp (YYYYMMDD)
588
+ */
589
+ private getDateStamp(date: Date): string {
590
+ return date.toISOString().slice(0, 10).replace(/-/g, '')
591
+ }
592
+
593
+ /**
594
+ * URI-encode a string per RFC 3986
595
+ */
596
+ private uriEncode(str: string): string {
597
+ return encodeURIComponent(str).replace(/[!'()*]/g, c =>
598
+ `%${c.charCodeAt(0).toString(16).toUpperCase()}`
599
+ )
600
+ }
601
+
602
+ /**
603
+ * SHA256 hash
604
+ */
605
+ private sha256(data: string): string {
606
+ return crypto.createHash('sha256').update(data, 'utf8').digest('hex')
607
+ }
608
+
609
+ /**
610
+ * HMAC SHA256
611
+ */
612
+ private hmac(key: Buffer | string, data: string): string {
613
+ return crypto.createHmac('sha256', key).update(data, 'utf8').digest('hex')
614
+ }
615
+
616
+ /**
617
+ * Get signature key
618
+ */
619
+ private getSignatureKey(key: string, dateStamp: string, region: string, service: string): Buffer {
620
+ const kDate = crypto.createHmac('sha256', `AWS4${key}`).update(dateStamp).digest()
621
+ const kRegion = crypto.createHmac('sha256', kDate).update(region).digest()
622
+ const kService = crypto.createHmac('sha256', kRegion).update(service).digest()
623
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
624
+ return kSigning
625
+ }
626
+
627
+ /**
628
+ * Parse XML response using fast-xml-parser
629
+ */
630
+ private parseXmlResponse(xml: string): any {
631
+ try {
632
+ const parsed = this.xmlParser.parse(xml)
633
+
634
+ // Extract the main result from common AWS response wrappers
635
+ if (parsed.ErrorResponse) {
636
+ throw this.createErrorFromXml(parsed.ErrorResponse.Error)
637
+ }
638
+
639
+ // Remove XML wrapper nodes to get to actual data
640
+ const keys = Object.keys(parsed)
641
+ if (keys.length === 1) {
642
+ return parsed[keys[0]]
643
+ }
644
+
645
+ return parsed
646
+ }
647
+ catch (error: any) {
648
+ // If parsing fails, return empty object
649
+ if (error.code || error.statusCode) {
650
+ throw error
651
+ }
652
+ return {}
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Parse error response and create detailed error object
658
+ */
659
+ private parseError(responseText: string, statusCode: number, headers: Headers): AWSError {
660
+ const error: AWSError = new Error() as AWSError
661
+ error.statusCode = statusCode
662
+ error.requestId = headers.get('x-amzn-requestid') || headers.get('x-amz-request-id') || undefined
663
+
664
+ // Try to parse XML error
665
+ if (responseText.startsWith('<')) {
666
+ try {
667
+ const parsed = this.xmlParser.parse(responseText)
668
+
669
+ if (parsed.ErrorResponse?.Error) {
670
+ const awsError = parsed.ErrorResponse.Error
671
+ error.code = awsError.Code
672
+ error.message = `AWS Error [${awsError.Code}]: ${awsError.Message || 'Unknown error'}`
673
+ error.type = awsError.Type
674
+ return error
675
+ }
676
+
677
+ if (parsed.Error) {
678
+ error.code = parsed.Error.Code
679
+ error.message = `AWS Error [${parsed.Error.Code}]: ${parsed.Error.Message || 'Unknown error'}`
680
+ return error
681
+ }
682
+ }
683
+ catch {
684
+ // Fall through to generic error
685
+ }
686
+ }
687
+
688
+ // Try to parse JSON error
689
+ try {
690
+ const json = JSON.parse(responseText)
691
+ if (json.__type || json.code) {
692
+ error.code = json.__type || json.code
693
+ error.message = `AWS Error [${error.code}]: ${json.message || json.Message || 'Unknown error'}`
694
+ return error
695
+ }
696
+ }
697
+ catch {
698
+ // Fall through to generic error
699
+ }
700
+
701
+ // Generic error
702
+ error.message = `AWS API Error (${statusCode}): ${responseText}`
703
+ return error
704
+ }
705
+
706
+ /**
707
+ * Create error from XML error object
708
+ */
709
+ private createErrorFromXml(errorData: any): AWSError {
710
+ const error: AWSError = new Error() as AWSError
711
+ error.code = errorData.Code
712
+ error.message = `AWS Error [${errorData.Code}]: ${errorData.Message || 'Unknown error'}`
713
+ error.type = errorData.Type
714
+ return error
715
+ }
716
+
717
+ /**
718
+ * Check if error code is retryable
719
+ */
720
+ private isRetryableError(code: string): boolean {
721
+ const retryableCodes = [
722
+ // Timeout errors
723
+ 'RequestTimeout',
724
+ 'RequestTimeoutException',
725
+ 'PriorRequestNotComplete',
726
+ 'ConnectionError',
727
+ // Throttling errors
728
+ 'ThrottlingException',
729
+ 'Throttling',
730
+ 'TooManyRequestsException',
731
+ 'ProvisionedThroughputExceededException',
732
+ 'RequestLimitExceeded',
733
+ 'BandwidthLimitExceeded',
734
+ 'SlowDown',
735
+ // Service errors
736
+ 'ServiceUnavailable',
737
+ 'ServiceUnavailableException',
738
+ 'InternalError',
739
+ 'InternalFailure',
740
+ 'InternalServerError',
741
+ 'InternalServiceException',
742
+ // Transient errors
743
+ 'TransientError',
744
+ 'TransientFailure',
745
+ 'IDPCommunicationErrorException',
746
+ // Network errors
747
+ 'NetworkError',
748
+ 'ConnectionRefusedException',
749
+ 'EC2ThrottledException',
750
+ // S3 specific
751
+ '503 Slow Down',
752
+ '500 InternalError',
753
+ ]
754
+ return retryableCodes.includes(code)
755
+ }
756
+
757
+ /**
758
+ * Check if HTTP status code is retryable
759
+ */
760
+ private isRetryableStatusCode(statusCode: number): boolean {
761
+ return statusCode >= 500 || statusCode === 429 // Server errors or Too Many Requests
762
+ }
763
+
764
+ /**
765
+ * Determine if an error should be retried
766
+ */
767
+ private shouldRetry(error: any): boolean {
768
+ // Network/fetch errors should be retried
769
+ if (error.name === 'FetchError' || error.name === 'TypeError' || error.code === 'ECONNRESET') {
770
+ return true
771
+ }
772
+
773
+ // Check by error code
774
+ if (error.code && this.isRetryableError(error.code)) {
775
+ return true
776
+ }
777
+
778
+ // Check by status code
779
+ if (error.statusCode && this.isRetryableStatusCode(error.statusCode)) {
780
+ return true
781
+ }
782
+
783
+ // Don't retry client errors (4xx) except 429
784
+ if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 429) {
785
+ return false
786
+ }
787
+
788
+ // Default: don't retry if we have a status code
789
+ if (error.statusCode) {
790
+ return false
791
+ }
792
+
793
+ // Unknown errors: retry once in case it's a transient issue
794
+ return true
795
+ }
796
+
797
+ /**
798
+ * Calculate retry delay with exponential backoff
799
+ */
800
+ private calculateRetryDelay(attempt: number): number {
801
+ const baseDelay = this.config.retryDelay ?? 1000
802
+ const maxDelay = 30000 // 30 seconds max
803
+ const delay = Math.min(baseDelay * (2 ** attempt), maxDelay)
804
+ // Add jitter to avoid thundering herd
805
+ const jitter = Math.random() * 0.3 * delay
806
+ return delay + jitter
807
+ }
808
+
809
+ /**
810
+ * Sleep for specified milliseconds
811
+ */
812
+ private sleep(ms: number): Promise<void> {
813
+ return new Promise(resolve => setTimeout(resolve, ms))
814
+ }
815
+
816
+ /**
817
+ * Get value from cache
818
+ */
819
+ private getFromCache(key: string): any | null {
820
+ const entry = this.cache.get(key)
821
+ if (!entry) {
822
+ return null
823
+ }
824
+
825
+ // Check if expired
826
+ if (Date.now() > entry.expires) {
827
+ this.cache.delete(key)
828
+ return null
829
+ }
830
+
831
+ return entry.data
832
+ }
833
+
834
+ /**
835
+ * Set value in cache
836
+ */
837
+ private setInCache(key: string, data: any, ttl: number): void {
838
+ this.cache.set(key, {
839
+ data,
840
+ expires: Date.now() + ttl,
841
+ })
842
+ }
843
+
844
+ /**
845
+ * Clear cache
846
+ */
847
+ clearCache(): void {
848
+ this.cache.clear()
849
+ }
850
+
851
+ /**
852
+ * Clear expired cache entries
853
+ */
854
+ clearExpiredCache(): void {
855
+ const now = Date.now()
856
+ for (const [key, entry] of this.cache.entries()) {
857
+ if (now > entry.expires) {
858
+ this.cache.delete(key)
859
+ }
860
+ }
861
+ }
862
+ }
863
+
864
+ /**
865
+ * Build query string for AWS API calls
866
+ */
867
+ export function buildQueryParams(params: Record<string, any>): Record<string, string> {
868
+ const result: Record<string, string> = {}
869
+
870
+ for (const [key, value] of Object.entries(params)) {
871
+ if (value === undefined || value === null) {
872
+ continue
873
+ }
874
+
875
+ if (Array.isArray(value)) {
876
+ value.forEach((item, index) => {
877
+ result[`${key}.${index + 1}`] = String(item)
878
+ })
879
+ }
880
+ else if (typeof value === 'object') {
881
+ for (const [subKey, subValue] of Object.entries(value)) {
882
+ result[`${key}.${subKey}`] = String(subValue)
883
+ }
884
+ }
885
+ else {
886
+ result[key] = String(value)
887
+ }
888
+ }
889
+
890
+ return result
891
+ }