@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.
- package/dist/aws/s3.d.ts +1 -1
- package/dist/bin/cli.js +223 -222
- package/dist/index.js +132 -132
- package/package.json +18 -16
- package/src/aws/acm.ts +768 -0
- package/src/aws/application-autoscaling.ts +845 -0
- package/src/aws/bedrock.ts +4074 -0
- package/src/aws/client.ts +891 -0
- package/src/aws/cloudformation.ts +896 -0
- package/src/aws/cloudfront.ts +1531 -0
- package/src/aws/cloudwatch-logs.ts +154 -0
- package/src/aws/comprehend.ts +839 -0
- package/src/aws/connect.ts +1056 -0
- package/src/aws/deploy-imap.ts +384 -0
- package/src/aws/dynamodb.ts +340 -0
- package/src/aws/ec2.ts +1385 -0
- package/src/aws/ecr.ts +621 -0
- package/src/aws/ecs.ts +615 -0
- package/src/aws/elasticache.ts +301 -0
- package/src/aws/elbv2.ts +942 -0
- package/src/aws/email.ts +928 -0
- package/src/aws/eventbridge.ts +248 -0
- package/src/aws/iam.ts +1689 -0
- package/src/aws/imap-server.ts +2100 -0
- package/src/aws/index.ts +213 -0
- package/src/aws/kendra.ts +1097 -0
- package/src/aws/lambda.ts +786 -0
- package/src/aws/opensearch.ts +158 -0
- package/src/aws/personalize.ts +977 -0
- package/src/aws/polly.ts +559 -0
- package/src/aws/rds.ts +888 -0
- package/src/aws/rekognition.ts +846 -0
- package/src/aws/route53-domains.ts +359 -0
- package/src/aws/route53.ts +1046 -0
- package/src/aws/s3.ts +2334 -0
- package/src/aws/scheduler.ts +571 -0
- package/src/aws/secrets-manager.ts +769 -0
- package/src/aws/ses.ts +1081 -0
- package/src/aws/setup-phone.ts +104 -0
- package/src/aws/setup-sms.ts +580 -0
- package/src/aws/sms.ts +1735 -0
- package/src/aws/smtp-server.ts +531 -0
- package/src/aws/sns.ts +758 -0
- package/src/aws/sqs.ts +382 -0
- package/src/aws/ssm.ts +807 -0
- package/src/aws/sts.ts +92 -0
- package/src/aws/support.ts +391 -0
- package/src/aws/test-imap.ts +86 -0
- package/src/aws/textract.ts +780 -0
- package/src/aws/transcribe.ts +108 -0
- package/src/aws/translate.ts +641 -0
- package/src/aws/voice.ts +1379 -0
- package/src/config.ts +35 -0
- package/src/deploy/index.ts +7 -0
- package/src/deploy/static-site-external-dns.ts +945 -0
- package/src/deploy/static-site.ts +1175 -0
- package/src/dns/cloudflare.ts +548 -0
- package/src/dns/godaddy.ts +412 -0
- package/src/dns/index.ts +205 -0
- package/src/dns/porkbun.ts +362 -0
- package/src/dns/route53-adapter.ts +414 -0
- package/src/dns/types.ts +119 -0
- package/src/dns/validator.ts +369 -0
- package/src/generators/index.ts +5 -0
- package/src/generators/infrastructure.ts +1660 -0
- package/src/index.ts +163 -0
- package/src/push/apns.ts +452 -0
- package/src/push/fcm.ts +506 -0
- package/src/push/index.ts +58 -0
- package/src/security/pre-deploy-scanner.ts +655 -0
- package/src/ssl/acme-client.ts +478 -0
- package/src/ssl/index.ts +7 -0
- package/src/ssl/letsencrypt.ts +747 -0
- package/src/types.ts +2 -0
- package/src/utils/cli.ts +398 -0
- package/src/validation/index.ts +5 -0
- package/src/validation/template.ts +405 -0
|
@@ -0,0 +1,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
|
+
}
|