@stacksjs/ts-cloud-core 0.1.1

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 (251) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +321 -0
  3. package/package.json +31 -0
  4. package/src/advanced-features.test.ts +465 -0
  5. package/src/aws/cloudformation.ts +421 -0
  6. package/src/aws/cloudfront.ts +158 -0
  7. package/src/aws/credentials.test.ts +132 -0
  8. package/src/aws/credentials.ts +545 -0
  9. package/src/aws/index.ts +87 -0
  10. package/src/aws/s3.test.ts +188 -0
  11. package/src/aws/s3.ts +1088 -0
  12. package/src/aws/signature.test.ts +670 -0
  13. package/src/aws/signature.ts +1155 -0
  14. package/src/backup/disaster-recovery.test.ts +726 -0
  15. package/src/backup/disaster-recovery.ts +500 -0
  16. package/src/backup/index.ts +34 -0
  17. package/src/backup/manager.test.ts +498 -0
  18. package/src/backup/manager.ts +432 -0
  19. package/src/cicd/circleci.ts +430 -0
  20. package/src/cicd/github-actions.ts +424 -0
  21. package/src/cicd/gitlab-ci.ts +255 -0
  22. package/src/cicd/index.ts +8 -0
  23. package/src/cli/history.ts +396 -0
  24. package/src/cli/index.ts +10 -0
  25. package/src/cli/progress.ts +458 -0
  26. package/src/cli/repl.ts +454 -0
  27. package/src/cli/suggestions.ts +327 -0
  28. package/src/cli/table.test.ts +319 -0
  29. package/src/cli/table.ts +332 -0
  30. package/src/cloudformation/builder.test.ts +327 -0
  31. package/src/cloudformation/builder.ts +378 -0
  32. package/src/cloudformation/builders/api-gateway.ts +449 -0
  33. package/src/cloudformation/builders/cache.ts +334 -0
  34. package/src/cloudformation/builders/cdn.ts +278 -0
  35. package/src/cloudformation/builders/compute.ts +485 -0
  36. package/src/cloudformation/builders/database.ts +392 -0
  37. package/src/cloudformation/builders/functions.ts +343 -0
  38. package/src/cloudformation/builders/messaging.ts +140 -0
  39. package/src/cloudformation/builders/monitoring.ts +300 -0
  40. package/src/cloudformation/builders/network.ts +264 -0
  41. package/src/cloudformation/builders/queue.ts +147 -0
  42. package/src/cloudformation/builders/security.ts +399 -0
  43. package/src/cloudformation/builders/storage.ts +285 -0
  44. package/src/cloudformation/index.ts +30 -0
  45. package/src/cloudformation/types.ts +173 -0
  46. package/src/compliance/aws-config.ts +543 -0
  47. package/src/compliance/cloudtrail.ts +376 -0
  48. package/src/compliance/compliance.test.ts +423 -0
  49. package/src/compliance/guardduty.ts +446 -0
  50. package/src/compliance/index.ts +66 -0
  51. package/src/compliance/security-hub.ts +456 -0
  52. package/src/containers/build-optimization.ts +416 -0
  53. package/src/containers/containers.test.ts +508 -0
  54. package/src/containers/image-scanning.ts +360 -0
  55. package/src/containers/index.ts +9 -0
  56. package/src/containers/registry.ts +293 -0
  57. package/src/containers/service-mesh.ts +520 -0
  58. package/src/database/database.test.ts +762 -0
  59. package/src/database/index.ts +9 -0
  60. package/src/database/migrations.ts +444 -0
  61. package/src/database/performance.ts +528 -0
  62. package/src/database/replicas.ts +534 -0
  63. package/src/database/users.ts +494 -0
  64. package/src/dependency-graph.ts +143 -0
  65. package/src/deployment/ab-testing.ts +582 -0
  66. package/src/deployment/blue-green.ts +452 -0
  67. package/src/deployment/canary.ts +500 -0
  68. package/src/deployment/deployment.test.ts +526 -0
  69. package/src/deployment/index.ts +61 -0
  70. package/src/deployment/progressive.ts +62 -0
  71. package/src/dns/dns.test.ts +641 -0
  72. package/src/dns/dnssec.ts +315 -0
  73. package/src/dns/index.ts +8 -0
  74. package/src/dns/resolver.ts +496 -0
  75. package/src/dns/routing.ts +593 -0
  76. package/src/email/advanced/analytics.ts +445 -0
  77. package/src/email/advanced/index.ts +11 -0
  78. package/src/email/advanced/rules.ts +465 -0
  79. package/src/email/advanced/scheduling.ts +352 -0
  80. package/src/email/advanced/search.ts +412 -0
  81. package/src/email/advanced/shared-mailboxes.ts +404 -0
  82. package/src/email/advanced/templates.ts +455 -0
  83. package/src/email/advanced/threading.ts +281 -0
  84. package/src/email/analytics.ts +467 -0
  85. package/src/email/bounce-handling.ts +425 -0
  86. package/src/email/email.test.ts +431 -0
  87. package/src/email/handlers/__tests__/inbound.test.ts +38 -0
  88. package/src/email/handlers/__tests__/outbound.test.ts +37 -0
  89. package/src/email/handlers/converter.ts +227 -0
  90. package/src/email/handlers/feedback.ts +228 -0
  91. package/src/email/handlers/inbound.ts +169 -0
  92. package/src/email/handlers/outbound.ts +178 -0
  93. package/src/email/index.ts +15 -0
  94. package/src/email/reputation.ts +303 -0
  95. package/src/email/templates.ts +352 -0
  96. package/src/errors/index.test.ts +434 -0
  97. package/src/errors/index.ts +416 -0
  98. package/src/health-checks/index.ts +40 -0
  99. package/src/index.ts +360 -0
  100. package/src/intrinsic-functions.ts +118 -0
  101. package/src/lambda/concurrency.ts +330 -0
  102. package/src/lambda/destinations.ts +345 -0
  103. package/src/lambda/dlq.ts +425 -0
  104. package/src/lambda/index.ts +11 -0
  105. package/src/lambda/lambda.test.ts +840 -0
  106. package/src/lambda/layers.ts +263 -0
  107. package/src/lambda/versions.ts +376 -0
  108. package/src/lambda/vpc.ts +399 -0
  109. package/src/local/config.ts +114 -0
  110. package/src/local/index.ts +6 -0
  111. package/src/local/mock-aws.ts +351 -0
  112. package/src/modules/ai.ts +340 -0
  113. package/src/modules/api.ts +478 -0
  114. package/src/modules/auth.ts +805 -0
  115. package/src/modules/cache.ts +417 -0
  116. package/src/modules/cdn.ts +1062 -0
  117. package/src/modules/communication.ts +1094 -0
  118. package/src/modules/compute.ts +3348 -0
  119. package/src/modules/database.ts +554 -0
  120. package/src/modules/deployment.ts +1079 -0
  121. package/src/modules/dns.ts +337 -0
  122. package/src/modules/email.ts +1538 -0
  123. package/src/modules/filesystem.ts +515 -0
  124. package/src/modules/index.ts +32 -0
  125. package/src/modules/messaging.ts +486 -0
  126. package/src/modules/monitoring.ts +2086 -0
  127. package/src/modules/network.ts +664 -0
  128. package/src/modules/parameter-store.ts +325 -0
  129. package/src/modules/permissions.ts +1081 -0
  130. package/src/modules/phone.ts +494 -0
  131. package/src/modules/queue.ts +1260 -0
  132. package/src/modules/redirects.ts +464 -0
  133. package/src/modules/registry.ts +699 -0
  134. package/src/modules/search.ts +401 -0
  135. package/src/modules/secrets.ts +416 -0
  136. package/src/modules/security.ts +731 -0
  137. package/src/modules/sms.ts +389 -0
  138. package/src/modules/storage.ts +1120 -0
  139. package/src/modules/workflow.ts +680 -0
  140. package/src/multi-account/config.ts +521 -0
  141. package/src/multi-account/index.ts +7 -0
  142. package/src/multi-account/manager.ts +427 -0
  143. package/src/multi-region/cross-region.ts +410 -0
  144. package/src/multi-region/index.ts +8 -0
  145. package/src/multi-region/manager.ts +483 -0
  146. package/src/multi-region/regions.ts +435 -0
  147. package/src/network-security/index.ts +48 -0
  148. package/src/observability/index.ts +9 -0
  149. package/src/observability/logs.ts +522 -0
  150. package/src/observability/metrics.ts +460 -0
  151. package/src/observability/observability.test.ts +782 -0
  152. package/src/observability/synthetics.ts +568 -0
  153. package/src/observability/xray.ts +358 -0
  154. package/src/phone/advanced/analytics.ts +349 -0
  155. package/src/phone/advanced/callbacks.ts +428 -0
  156. package/src/phone/advanced/index.ts +8 -0
  157. package/src/phone/advanced/ivr-builder.ts +504 -0
  158. package/src/phone/advanced/recording.ts +310 -0
  159. package/src/phone/handlers/__tests__/incoming-call.test.ts +40 -0
  160. package/src/phone/handlers/incoming-call.ts +117 -0
  161. package/src/phone/handlers/missed-call.ts +116 -0
  162. package/src/phone/handlers/voicemail.ts +179 -0
  163. package/src/phone/index.ts +9 -0
  164. package/src/presets/api-backend.ts +134 -0
  165. package/src/presets/data-pipeline.ts +204 -0
  166. package/src/presets/extend.test.ts +295 -0
  167. package/src/presets/extend.ts +297 -0
  168. package/src/presets/fullstack-app.ts +144 -0
  169. package/src/presets/index.ts +27 -0
  170. package/src/presets/jamstack.ts +135 -0
  171. package/src/presets/microservices.ts +167 -0
  172. package/src/presets/ml-api.ts +208 -0
  173. package/src/presets/nodejs-server.ts +104 -0
  174. package/src/presets/nodejs-serverless.ts +114 -0
  175. package/src/presets/realtime-app.ts +184 -0
  176. package/src/presets/static-site.ts +64 -0
  177. package/src/presets/traditional-web-app.ts +339 -0
  178. package/src/presets/wordpress.ts +138 -0
  179. package/src/preview/github.test.ts +249 -0
  180. package/src/preview/github.ts +297 -0
  181. package/src/preview/index.ts +37 -0
  182. package/src/preview/manager.test.ts +440 -0
  183. package/src/preview/manager.ts +326 -0
  184. package/src/preview/notifications.test.ts +582 -0
  185. package/src/preview/notifications.ts +341 -0
  186. package/src/queue/batch-processing.ts +402 -0
  187. package/src/queue/dlq-monitoring.ts +402 -0
  188. package/src/queue/fifo.ts +342 -0
  189. package/src/queue/index.ts +9 -0
  190. package/src/queue/management.ts +428 -0
  191. package/src/queue/queue.test.ts +429 -0
  192. package/src/resource-mgmt/index.ts +39 -0
  193. package/src/resource-naming.ts +62 -0
  194. package/src/s3/index.ts +523 -0
  195. package/src/schema/cloud-config.schema.json +554 -0
  196. package/src/schema/index.ts +68 -0
  197. package/src/security/certificate-manager.ts +492 -0
  198. package/src/security/index.ts +9 -0
  199. package/src/security/scanning.ts +545 -0
  200. package/src/security/secrets-manager.ts +476 -0
  201. package/src/security/secrets-rotation.ts +456 -0
  202. package/src/security/security.test.ts +738 -0
  203. package/src/sms/advanced/ab-testing.ts +389 -0
  204. package/src/sms/advanced/analytics.ts +336 -0
  205. package/src/sms/advanced/campaigns.ts +523 -0
  206. package/src/sms/advanced/chatbot.ts +224 -0
  207. package/src/sms/advanced/index.ts +10 -0
  208. package/src/sms/advanced/link-tracking.ts +248 -0
  209. package/src/sms/advanced/mms.ts +308 -0
  210. package/src/sms/handlers/__tests__/send.test.ts +40 -0
  211. package/src/sms/handlers/delivery-status.ts +133 -0
  212. package/src/sms/handlers/receive.ts +162 -0
  213. package/src/sms/handlers/send.ts +174 -0
  214. package/src/sms/index.ts +9 -0
  215. package/src/stack-diff.ts +389 -0
  216. package/src/static-site/index.ts +85 -0
  217. package/src/template-builder.ts +110 -0
  218. package/src/template-validator.ts +574 -0
  219. package/src/utils/cache.ts +291 -0
  220. package/src/utils/diff.ts +269 -0
  221. package/src/utils/hash.ts +227 -0
  222. package/src/utils/index.ts +8 -0
  223. package/src/utils/parallel.ts +294 -0
  224. package/src/validators/credentials.test.ts +274 -0
  225. package/src/validators/credentials.ts +233 -0
  226. package/src/validators/quotas.test.ts +434 -0
  227. package/src/validators/quotas.ts +217 -0
  228. package/test/ai.test.ts +327 -0
  229. package/test/api.test.ts +511 -0
  230. package/test/auth.test.ts +632 -0
  231. package/test/cache.test.ts +406 -0
  232. package/test/cdn.test.ts +247 -0
  233. package/test/compute.test.ts +861 -0
  234. package/test/database.test.ts +523 -0
  235. package/test/deployment.test.ts +499 -0
  236. package/test/dns.test.ts +270 -0
  237. package/test/email.test.ts +439 -0
  238. package/test/filesystem.test.ts +382 -0
  239. package/test/integration.test.ts +350 -0
  240. package/test/messaging.test.ts +514 -0
  241. package/test/monitoring.test.ts +634 -0
  242. package/test/network.test.ts +425 -0
  243. package/test/permissions.test.ts +488 -0
  244. package/test/queue.test.ts +484 -0
  245. package/test/registry.test.ts +306 -0
  246. package/test/security.test.ts +462 -0
  247. package/test/storage.test.ts +463 -0
  248. package/test/template-validator.test.ts +559 -0
  249. package/test/workflow.test.ts +592 -0
  250. package/tsconfig.json +16 -0
  251. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,1155 @@
1
+ /**
2
+ * AWS Signature Version 4 Signing Process
3
+ * Implements request signing for direct AWS API calls without SDK
4
+ *
5
+ * Reference: https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
6
+ *
7
+ * Browser compatible: Use async functions (signRequestAsync, createPresignedUrlAsync)
8
+ * Node.js/Bun: Use sync functions for better performance (signRequest, createPresignedUrl)
9
+ */
10
+
11
+ // Conditional import for Node.js crypto - will be undefined in browser
12
+ let nodeCrypto: typeof import('node:crypto') | undefined
13
+ try {
14
+ nodeCrypto = await import('node:crypto')
15
+ } catch {
16
+ // Running in browser - nodeCrypto stays undefined
17
+ }
18
+
19
+ /**
20
+ * Signing key cache for improved performance on repeated requests
21
+ * Keys are cached by: secretAccessKey + date + region + service
22
+ * Supports both Buffer (Node.js) and Uint8Array (browser)
23
+ */
24
+ const signingKeyCache = new Map<string, Buffer | Uint8Array>()
25
+ const MAX_CACHE_SIZE = 100
26
+
27
+ /**
28
+ * Service name mappings for hosts that don't follow standard naming
29
+ */
30
+ const HOST_SERVICES: Record<string, string> = {
31
+ 'appstream2': 'appstream',
32
+ 'cloudhsmv2': 'cloudhsm',
33
+ 'email': 'ses',
34
+ 'marketplace': 'aws-marketplace',
35
+ 'mobile': 'AWSMobileHubService',
36
+ 'pinpoint': 'mobiletargeting',
37
+ 'queue': 'sqs',
38
+ 'git-codecommit': 'codecommit',
39
+ 'mturk-requester-sandbox': 'mturk-requester',
40
+ 'personalize-runtime': 'personalize',
41
+ }
42
+
43
+ export interface SignatureOptions {
44
+ method: string
45
+ url: string
46
+ /** Service name - if not provided, will be auto-detected from URL */
47
+ service?: string
48
+ /** Region - if not provided, will be auto-detected from URL */
49
+ region?: string
50
+ headers?: Record<string, string>
51
+ body?: string
52
+ accessKeyId: string
53
+ secretAccessKey: string
54
+ sessionToken?: string
55
+ /**
56
+ * Optional external cache for signing keys
57
+ * If not provided, uses internal cache
58
+ * Supports both Buffer (Node.js) and Uint8Array (browser)
59
+ */
60
+ cache?: Map<string, Buffer | Uint8Array>
61
+ /**
62
+ * Sign via query string instead of Authorization header
63
+ * Used for presigned URLs (e.g., S3 presigned URLs)
64
+ */
65
+ signQuery?: boolean
66
+ /**
67
+ * Expiration time in seconds for presigned URLs (default: 86400 = 24 hours)
68
+ * Only used when signQuery is true
69
+ */
70
+ expiresIn?: number
71
+ /**
72
+ * Custom datetime for signing (format: YYYYMMDDTHHMMSSZ)
73
+ * If not provided, uses current time
74
+ * Useful for testing and reproducibility
75
+ */
76
+ datetime?: string
77
+ }
78
+
79
+ export interface SignedRequest {
80
+ url: string
81
+ method: string
82
+ headers: Record<string, string>
83
+ body?: string
84
+ }
85
+
86
+ export interface PresignedUrlOptions {
87
+ url: string
88
+ method?: string
89
+ accessKeyId: string
90
+ secretAccessKey: string
91
+ sessionToken?: string
92
+ /** Service name - if not provided, will be auto-detected from URL */
93
+ service?: string
94
+ /** Region - if not provided, will be auto-detected from URL */
95
+ region?: string
96
+ /** Expiration time in seconds (default: 3600 = 1 hour, max: 604800 = 7 days) */
97
+ expiresIn?: number
98
+ /** Optional external cache for signing keys */
99
+ cache?: Map<string, Buffer | Uint8Array>
100
+ }
101
+
102
+ export interface RetryOptions {
103
+ /** Maximum number of retry attempts (default: 3) */
104
+ maxRetries?: number
105
+ /** Initial delay in ms before first retry (default: 100) */
106
+ initialDelayMs?: number
107
+ /** Maximum delay in ms between retries (default: 5000) */
108
+ maxDelayMs?: number
109
+ /** HTTP status codes that should trigger a retry (default: [429, 500, 502, 503, 504]) */
110
+ retryableStatusCodes?: number[]
111
+ /** Request timeout in milliseconds (default: 30000 = 30 seconds) */
112
+ timeoutMs?: number
113
+ }
114
+
115
+ /**
116
+ * Detect service and region from AWS URL
117
+ * Supports standard AWS endpoints, Lambda URLs, R2, and Backblaze B2
118
+ */
119
+ export function detectServiceRegion(url: string | URL): { service: string, region: string } {
120
+ const urlObj = typeof url === 'string' ? new URL(url) : url
121
+ const { hostname, pathname } = urlObj
122
+
123
+ // Lambda function URLs: xxx.lambda-url.region.on.aws
124
+ if (hostname.endsWith('.on.aws')) {
125
+ const match = hostname.match(/^[^.]+\.lambda-url\.([^.]+)\.on\.aws$/)
126
+ if (match) {
127
+ return { service: 'lambda', region: match[1] }
128
+ }
129
+ return { service: '', region: '' }
130
+ }
131
+
132
+ // Cloudflare R2: xxx.r2.cloudflarestorage.com
133
+ if (hostname.endsWith('.r2.cloudflarestorage.com')) {
134
+ return { service: 's3', region: 'auto' }
135
+ }
136
+
137
+ // Backblaze B2: xxx.s3.region.backblazeb2.com
138
+ if (hostname.endsWith('.backblazeb2.com')) {
139
+ const match = hostname.match(/^(?:[^.]+\.)?s3\.([^.]+)\.backblazeb2\.com$/)
140
+ if (match) {
141
+ return { service: 's3', region: match[1] }
142
+ }
143
+ return { service: '', region: '' }
144
+ }
145
+
146
+ // Standard AWS endpoints: service.region.amazonaws.com
147
+ const match = hostname
148
+ .replace('dualstack.', '')
149
+ .match(/([^.]+)\.(?:([^.]+)\.)?amazonaws\.com(?:\.cn)?$/)
150
+
151
+ if (!match) {
152
+ return { service: '', region: '' }
153
+ }
154
+
155
+ let service = match[1]
156
+ let region = match[2] || ''
157
+
158
+ // Handle special cases
159
+ if (region === 'us-gov') {
160
+ region = 'us-gov-west-1'
161
+ } else if (region === 's3' || region === 's3-accelerate') {
162
+ region = 'us-east-1'
163
+ service = 's3'
164
+ } else if (service === 'iot') {
165
+ if (hostname.startsWith('iot.')) {
166
+ service = 'execute-api'
167
+ } else if (hostname.startsWith('data.jobs.iot.')) {
168
+ service = 'iot-jobs-data'
169
+ } else {
170
+ service = pathname === '/mqtt' ? 'iotdevicegateway' : 'iotdata'
171
+ }
172
+ } else if (service === 'autoscaling') {
173
+ // Could be application-autoscaling or autoscaling-plans based on target
174
+ // Default to autoscaling
175
+ } else if (!region && service.startsWith('s3-')) {
176
+ region = service.slice(3).replace(/^fips-|^external-1/, '')
177
+ service = 's3'
178
+ } else if (service.endsWith('-fips')) {
179
+ service = service.slice(0, -5)
180
+ } else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) {
181
+ // Swap service and region if they appear reversed
182
+ [service, region] = [region, service]
183
+ }
184
+
185
+ // Apply service name mappings
186
+ service = HOST_SERVICES[service] || service
187
+
188
+ return { service, region: region || 'us-east-1' }
189
+ }
190
+
191
+ /**
192
+ * Sign an AWS request using Signature Version 4
193
+ */
194
+ export function signRequest(options: SignatureOptions): SignedRequest {
195
+ const {
196
+ method,
197
+ url,
198
+ body = '',
199
+ accessKeyId,
200
+ secretAccessKey,
201
+ sessionToken,
202
+ signQuery = false,
203
+ expiresIn = 86400,
204
+ datetime,
205
+ } = options
206
+
207
+ const urlObj = new URL(url)
208
+ const host = urlObj.hostname
209
+
210
+ // Auto-detect service and region if not provided
211
+ const detected = detectServiceRegion(urlObj)
212
+ const service = options.service || detected.service
213
+ const region = options.region || detected.region
214
+
215
+ if (!service) {
216
+ throw new Error('Could not detect service from URL. Please provide service explicitly.')
217
+ }
218
+ if (!region) {
219
+ throw new Error('Could not detect region from URL. Please provide region explicitly.')
220
+ }
221
+
222
+ // Step 1: Create canonical request
223
+ const timestamp = datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
224
+ const date = timestamp.substring(0, 8)
225
+ const credentialScope = [date, region, service, 'aws4_request'].join('/')
226
+ const algorithm = 'AWS4-HMAC-SHA256'
227
+
228
+ if (signQuery) {
229
+ // Query string signing (for presigned URLs)
230
+ return signWithQueryString({
231
+ urlObj,
232
+ method,
233
+ body,
234
+ accessKeyId,
235
+ secretAccessKey,
236
+ sessionToken,
237
+ service,
238
+ region,
239
+ timestamp,
240
+ date,
241
+ credentialScope,
242
+ algorithm,
243
+ expiresIn,
244
+ cache: options.cache,
245
+ })
246
+ }
247
+
248
+ // Header-based signing
249
+ const path = urlObj.pathname || '/'
250
+ const query = canonicalQueryString(urlObj.searchParams)
251
+
252
+ const headers: Record<string, string> = {
253
+ 'host': host,
254
+ 'x-amz-date': timestamp,
255
+ ...options.headers,
256
+ }
257
+
258
+ if (sessionToken) {
259
+ headers['x-amz-security-token'] = sessionToken
260
+ }
261
+
262
+ // Add content-type for requests with body
263
+ if (body && !headers['content-type']) {
264
+ headers['content-type'] = 'application/x-amz-json-1.0'
265
+ }
266
+
267
+ // For S3, add content hash header
268
+ if (service === 's3' && !headers['x-amz-content-sha256']) {
269
+ headers['x-amz-content-sha256'] = hash(body)
270
+ }
271
+
272
+ const canonicalHeaders = getCanonicalHeaders(headers)
273
+ const signedHeaders = getSignedHeaders(headers)
274
+ const payloadHash = headers['x-amz-content-sha256'] || hash(body)
275
+
276
+ const canonicalRequest = [
277
+ method,
278
+ encodePath(path),
279
+ query,
280
+ canonicalHeaders,
281
+ signedHeaders,
282
+ payloadHash,
283
+ ].join('\n')
284
+
285
+ // Step 2: Create string to sign
286
+ const canonicalRequestHash = hash(canonicalRequest)
287
+
288
+ const stringToSign = [
289
+ algorithm,
290
+ timestamp,
291
+ credentialScope,
292
+ canonicalRequestHash,
293
+ ].join('\n')
294
+
295
+ // Step 3: Calculate signature (with key caching for performance)
296
+ const cache = options.cache ?? signingKeyCache
297
+ const signature = calculateSignature(
298
+ secretAccessKey,
299
+ date,
300
+ region,
301
+ service,
302
+ stringToSign,
303
+ cache,
304
+ )
305
+
306
+ // Step 4: Add authorization header
307
+ const authorization = [
308
+ `${algorithm} Credential=${accessKeyId}/${credentialScope}`,
309
+ `SignedHeaders=${signedHeaders}`,
310
+ `Signature=${signature}`,
311
+ ].join(', ')
312
+
313
+ headers['authorization'] = authorization
314
+
315
+ return {
316
+ url,
317
+ method,
318
+ headers,
319
+ body: body || undefined,
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Sign an AWS request using Signature Version 4 (async - browser compatible)
325
+ * Use this in browser environments where crypto.subtle is available
326
+ */
327
+ export async function signRequestAsync(options: SignatureOptions): Promise<SignedRequest> {
328
+ const {
329
+ method,
330
+ url,
331
+ body = '',
332
+ accessKeyId,
333
+ secretAccessKey,
334
+ sessionToken,
335
+ signQuery = false,
336
+ expiresIn = 86400,
337
+ datetime,
338
+ } = options
339
+
340
+ const urlObj = new URL(url)
341
+ const host = urlObj.hostname
342
+
343
+ // Auto-detect service and region if not provided
344
+ const detected = detectServiceRegion(urlObj)
345
+ const service = options.service || detected.service
346
+ const region = options.region || detected.region
347
+
348
+ if (!service) {
349
+ throw new Error('Could not detect service from URL. Please provide service explicitly.')
350
+ }
351
+ if (!region) {
352
+ throw new Error('Could not detect region from URL. Please provide region explicitly.')
353
+ }
354
+
355
+ // Step 1: Create canonical request
356
+ const timestamp = datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
357
+ const date = timestamp.substring(0, 8)
358
+ const credentialScope = [date, region, service, 'aws4_request'].join('/')
359
+ const algorithm = 'AWS4-HMAC-SHA256'
360
+
361
+ if (signQuery) {
362
+ // Query string signing (for presigned URLs)
363
+ return signWithQueryStringAsync({
364
+ urlObj,
365
+ method,
366
+ body,
367
+ accessKeyId,
368
+ secretAccessKey,
369
+ sessionToken,
370
+ service,
371
+ region,
372
+ timestamp,
373
+ date,
374
+ credentialScope,
375
+ algorithm,
376
+ expiresIn,
377
+ cache: options.cache,
378
+ })
379
+ }
380
+
381
+ // Header-based signing
382
+ const path = urlObj.pathname || '/'
383
+ const query = canonicalQueryString(urlObj.searchParams)
384
+
385
+ const headers: Record<string, string> = {
386
+ 'host': host,
387
+ 'x-amz-date': timestamp,
388
+ ...options.headers,
389
+ }
390
+
391
+ if (sessionToken) {
392
+ headers['x-amz-security-token'] = sessionToken
393
+ }
394
+
395
+ // Add content-type for requests with body
396
+ if (body && !headers['content-type']) {
397
+ headers['content-type'] = 'application/x-amz-json-1.0'
398
+ }
399
+
400
+ // For S3, add content hash header
401
+ const bodyHash = await hashAsync(body)
402
+ if (service === 's3' && !headers['x-amz-content-sha256']) {
403
+ headers['x-amz-content-sha256'] = bodyHash
404
+ }
405
+
406
+ const canonicalHeaders = getCanonicalHeaders(headers)
407
+ const signedHeaders = getSignedHeaders(headers)
408
+ const payloadHash = headers['x-amz-content-sha256'] || bodyHash
409
+
410
+ const canonicalRequest = [
411
+ method,
412
+ encodePath(path),
413
+ query,
414
+ canonicalHeaders,
415
+ signedHeaders,
416
+ payloadHash,
417
+ ].join('\n')
418
+
419
+ // Step 2: Create string to sign
420
+ const canonicalRequestHash = await hashAsync(canonicalRequest)
421
+
422
+ const stringToSign = [
423
+ algorithm,
424
+ timestamp,
425
+ credentialScope,
426
+ canonicalRequestHash,
427
+ ].join('\n')
428
+
429
+ // Step 3: Calculate signature (with key caching for performance)
430
+ const cache = options.cache ?? signingKeyCache
431
+ const signature = await calculateSignatureAsync(
432
+ secretAccessKey,
433
+ date,
434
+ region,
435
+ service,
436
+ stringToSign,
437
+ cache,
438
+ )
439
+
440
+ // Step 4: Add authorization header
441
+ const authorization = [
442
+ `${algorithm} Credential=${accessKeyId}/${credentialScope}`,
443
+ `SignedHeaders=${signedHeaders}`,
444
+ `Signature=${signature}`,
445
+ ].join(', ')
446
+
447
+ headers['authorization'] = authorization
448
+
449
+ return {
450
+ url,
451
+ method,
452
+ headers,
453
+ body: body || undefined,
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Sign request using query string parameters (for presigned URLs)
459
+ */
460
+ function signWithQueryString(params: {
461
+ urlObj: URL
462
+ method: string
463
+ body: string
464
+ accessKeyId: string
465
+ secretAccessKey: string
466
+ sessionToken?: string
467
+ service: string
468
+ region: string
469
+ timestamp: string
470
+ date: string
471
+ credentialScope: string
472
+ algorithm: string
473
+ expiresIn: number
474
+ cache?: Map<string, Buffer | Uint8Array>
475
+ }): SignedRequest {
476
+ const {
477
+ urlObj,
478
+ method,
479
+ body,
480
+ accessKeyId,
481
+ secretAccessKey,
482
+ sessionToken,
483
+ service,
484
+ region,
485
+ timestamp,
486
+ date,
487
+ credentialScope,
488
+ algorithm,
489
+ expiresIn,
490
+ cache,
491
+ } = params
492
+
493
+ // Clone URL to avoid modifying original
494
+ const signedUrl = new URL(urlObj.toString())
495
+
496
+ // Add required query parameters
497
+ signedUrl.searchParams.set('X-Amz-Algorithm', algorithm)
498
+ signedUrl.searchParams.set('X-Amz-Credential', `${accessKeyId}/${credentialScope}`)
499
+ signedUrl.searchParams.set('X-Amz-Date', timestamp)
500
+ signedUrl.searchParams.set('X-Amz-Expires', String(expiresIn))
501
+
502
+ // For S3, default to UNSIGNED-PAYLOAD
503
+ const payloadHash = service === 's3' ? 'UNSIGNED-PAYLOAD' : hash(body)
504
+ if (service === 's3') {
505
+ signedUrl.searchParams.set('X-Amz-Content-Sha256', payloadHash)
506
+ }
507
+
508
+ // Signed headers (only host for query string signing)
509
+ const signedHeaders = 'host'
510
+ signedUrl.searchParams.set('X-Amz-SignedHeaders', signedHeaders)
511
+
512
+ if (sessionToken) {
513
+ signedUrl.searchParams.set('X-Amz-Security-Token', sessionToken)
514
+ }
515
+
516
+ // Build canonical request
517
+ const path = encodePath(signedUrl.pathname || '/')
518
+ const canonicalHeaders = `host:${signedUrl.hostname}\n`
519
+ const query = canonicalQueryString(signedUrl.searchParams)
520
+
521
+ const canonicalRequest = [
522
+ method,
523
+ path,
524
+ query,
525
+ canonicalHeaders,
526
+ signedHeaders,
527
+ payloadHash,
528
+ ].join('\n')
529
+
530
+ // Create string to sign
531
+ const stringToSign = [
532
+ algorithm,
533
+ timestamp,
534
+ credentialScope,
535
+ hash(canonicalRequest),
536
+ ].join('\n')
537
+
538
+ // Calculate signature
539
+ const signingCache = cache ?? signingKeyCache
540
+ const signature = calculateSignature(
541
+ secretAccessKey,
542
+ date,
543
+ region,
544
+ service,
545
+ stringToSign,
546
+ signingCache,
547
+ )
548
+
549
+ // Add signature to URL
550
+ signedUrl.searchParams.set('X-Amz-Signature', signature)
551
+
552
+ return {
553
+ url: signedUrl.toString(),
554
+ method,
555
+ headers: {},
556
+ body: body || undefined,
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Sign request using query string parameters (async - browser compatible)
562
+ */
563
+ async function signWithQueryStringAsync(params: {
564
+ urlObj: URL
565
+ method: string
566
+ body: string
567
+ accessKeyId: string
568
+ secretAccessKey: string
569
+ sessionToken?: string
570
+ service: string
571
+ region: string
572
+ timestamp: string
573
+ date: string
574
+ credentialScope: string
575
+ algorithm: string
576
+ expiresIn: number
577
+ cache?: Map<string, Buffer | Uint8Array>
578
+ }): Promise<SignedRequest> {
579
+ const {
580
+ urlObj,
581
+ method,
582
+ body,
583
+ accessKeyId,
584
+ secretAccessKey,
585
+ sessionToken,
586
+ service,
587
+ region,
588
+ timestamp,
589
+ date,
590
+ credentialScope,
591
+ algorithm,
592
+ expiresIn,
593
+ cache,
594
+ } = params
595
+
596
+ // Clone URL to avoid modifying original
597
+ const signedUrl = new URL(urlObj.toString())
598
+
599
+ // Add required query parameters
600
+ signedUrl.searchParams.set('X-Amz-Algorithm', algorithm)
601
+ signedUrl.searchParams.set('X-Amz-Credential', `${accessKeyId}/${credentialScope}`)
602
+ signedUrl.searchParams.set('X-Amz-Date', timestamp)
603
+ signedUrl.searchParams.set('X-Amz-Expires', String(expiresIn))
604
+
605
+ // For S3, default to UNSIGNED-PAYLOAD
606
+ const payloadHash = service === 's3' ? 'UNSIGNED-PAYLOAD' : await hashAsync(body)
607
+ if (service === 's3') {
608
+ signedUrl.searchParams.set('X-Amz-Content-Sha256', payloadHash)
609
+ }
610
+
611
+ // Signed headers (only host for query string signing)
612
+ const signedHeaders = 'host'
613
+ signedUrl.searchParams.set('X-Amz-SignedHeaders', signedHeaders)
614
+
615
+ if (sessionToken) {
616
+ signedUrl.searchParams.set('X-Amz-Security-Token', sessionToken)
617
+ }
618
+
619
+ // Build canonical request
620
+ const path = encodePath(signedUrl.pathname || '/')
621
+ const canonicalHeaders = `host:${signedUrl.hostname}\n`
622
+ const query = canonicalQueryString(signedUrl.searchParams)
623
+
624
+ const canonicalRequest = [
625
+ method,
626
+ path,
627
+ query,
628
+ canonicalHeaders,
629
+ signedHeaders,
630
+ payloadHash,
631
+ ].join('\n')
632
+
633
+ // Create string to sign
634
+ const stringToSign = [
635
+ algorithm,
636
+ timestamp,
637
+ credentialScope,
638
+ await hashAsync(canonicalRequest),
639
+ ].join('\n')
640
+
641
+ // Calculate signature
642
+ const signingCache = cache ?? signingKeyCache
643
+ const signature = await calculateSignatureAsync(
644
+ secretAccessKey,
645
+ date,
646
+ region,
647
+ service,
648
+ stringToSign,
649
+ signingCache,
650
+ )
651
+
652
+ // Add signature to URL
653
+ signedUrl.searchParams.set('X-Amz-Signature', signature)
654
+
655
+ return {
656
+ url: signedUrl.toString(),
657
+ method,
658
+ headers: {},
659
+ body: body || undefined,
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Generate a presigned URL for AWS requests (e.g., S3 GetObject, PutObject)
665
+ */
666
+ export function createPresignedUrl(options: PresignedUrlOptions): string {
667
+ const {
668
+ url,
669
+ method = 'GET',
670
+ expiresIn = 3600,
671
+ ...rest
672
+ } = options
673
+
674
+ // Max expiration is 7 days for most services
675
+ const clampedExpires = Math.min(expiresIn, 604800)
676
+
677
+ const signed = signRequest({
678
+ ...rest,
679
+ url,
680
+ method,
681
+ signQuery: true,
682
+ expiresIn: clampedExpires,
683
+ })
684
+
685
+ return signed.url
686
+ }
687
+
688
+ /**
689
+ * Generate a presigned URL for AWS requests (async - browser compatible)
690
+ */
691
+ export async function createPresignedUrlAsync(options: PresignedUrlOptions): Promise<string> {
692
+ const {
693
+ url,
694
+ method = 'GET',
695
+ expiresIn = 3600,
696
+ ...rest
697
+ } = options
698
+
699
+ // Max expiration is 7 days for most services
700
+ const clampedExpires = Math.min(expiresIn, 604800)
701
+
702
+ const signed = await signRequestAsync({
703
+ ...rest,
704
+ url,
705
+ method,
706
+ signQuery: true,
707
+ expiresIn: clampedExpires,
708
+ })
709
+
710
+ return signed.url
711
+ }
712
+
713
+ /**
714
+ * Create canonical headers string
715
+ */
716
+ function getCanonicalHeaders(headers: Record<string, string>): string {
717
+ return Object.keys(headers)
718
+ .sort()
719
+ .map(key => `${key.toLowerCase()}:${headers[key].trim().replace(/\s+/g, ' ')}`)
720
+ .join('\n') + '\n'
721
+ }
722
+
723
+ /**
724
+ * Get signed headers string
725
+ */
726
+ function getSignedHeaders(headers: Record<string, string>): string {
727
+ return Object.keys(headers)
728
+ .sort()
729
+ .map(key => key.toLowerCase())
730
+ .join(';')
731
+ }
732
+
733
+ /**
734
+ * Create canonical query string (sorted and encoded)
735
+ */
736
+ function canonicalQueryString(params: URLSearchParams): string {
737
+ const sorted: Array<[string, string]> = []
738
+
739
+ params.forEach((value, key) => {
740
+ sorted.push([encodeRfc3986(key), encodeRfc3986(value)])
741
+ })
742
+
743
+ sorted.sort((a, b) => {
744
+ if (a[0] < b[0]) return -1
745
+ if (a[0] > b[0]) return 1
746
+ if (a[1] < b[1]) return -1
747
+ if (a[1] > b[1]) return 1
748
+ return 0
749
+ })
750
+
751
+ return sorted.map(([k, v]) => `${k}=${v}`).join('&')
752
+ }
753
+
754
+ /**
755
+ * Encode path for canonical request
756
+ */
757
+ function encodePath(path: string): string {
758
+ return path
759
+ .split('/')
760
+ .map(segment => encodeRfc3986(segment))
761
+ .join('/')
762
+ }
763
+
764
+ /**
765
+ * RFC 3986 URI encoding (stricter than encodeURIComponent)
766
+ */
767
+ function encodeRfc3986(str: string): string {
768
+ return encodeURIComponent(str).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
769
+ }
770
+
771
+ /**
772
+ * Calculate SHA256 hash (synchronous - Node.js/Bun only)
773
+ */
774
+ function hash(data: string): string {
775
+ if (!nodeCrypto) {
776
+ throw new Error('Synchronous hash not available in browser. Use signRequestAsync() instead.')
777
+ }
778
+ return nodeCrypto.createHash('sha256').update(data, 'utf8').digest('hex')
779
+ }
780
+
781
+ /**
782
+ * Calculate HMAC SHA256 (synchronous - Node.js/Bun only)
783
+ */
784
+ function hmac(key: Buffer | string, data: string): Buffer {
785
+ if (!nodeCrypto) {
786
+ throw new Error('Synchronous hmac not available in browser. Use signRequestAsync() instead.')
787
+ }
788
+ return nodeCrypto.createHmac('sha256', key).update(data, 'utf8').digest()
789
+ }
790
+
791
+ /**
792
+ * Calculate SHA256 hash (async - browser compatible)
793
+ */
794
+ async function hashAsync(data: string): Promise<string> {
795
+ const encoder = new TextEncoder()
796
+ const dataBuffer = encoder.encode(data)
797
+ const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
798
+ return bufferToHex(new Uint8Array(hashBuffer))
799
+ }
800
+
801
+ /**
802
+ * Calculate HMAC SHA256 (async - browser compatible)
803
+ */
804
+ async function hmacAsync(key: Uint8Array | string, data: string): Promise<Uint8Array> {
805
+ const encoder = new TextEncoder()
806
+ const keyBuffer = typeof key === 'string' ? encoder.encode(key) : key
807
+ const dataBuffer = encoder.encode(data)
808
+
809
+ const cryptoKey = await crypto.subtle.importKey(
810
+ 'raw',
811
+ keyBuffer.buffer as ArrayBuffer,
812
+ { name: 'HMAC', hash: 'SHA-256' },
813
+ false,
814
+ ['sign'],
815
+ )
816
+
817
+ const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataBuffer.buffer)
818
+ return new Uint8Array(signature)
819
+ }
820
+
821
+ /**
822
+ * Convert Uint8Array to hex string
823
+ */
824
+ function bufferToHex(buffer: Uint8Array): string {
825
+ return Array.from(buffer)
826
+ .map(b => b.toString(16).padStart(2, '0'))
827
+ .join('')
828
+ }
829
+
830
+ /**
831
+ * Calculate signature using AWS signing key derivation (sync - Node.js/Bun only)
832
+ * Uses caching for signing keys to improve performance on repeated requests
833
+ */
834
+ function calculateSignature(
835
+ secretAccessKey: string,
836
+ date: string,
837
+ region: string,
838
+ service: string,
839
+ stringToSign: string,
840
+ cache: Map<string, Buffer | Uint8Array>,
841
+ ): string {
842
+ // Create cache key from signing parameters
843
+ const cacheKey = `${secretAccessKey}:${date}:${region}:${service}`
844
+
845
+ let kSigning = cache.get(cacheKey) as Buffer | undefined
846
+
847
+ if (!kSigning) {
848
+ // Derive signing key (expensive operation)
849
+ const kDate = hmac(`AWS4${secretAccessKey}`, date)
850
+ const kRegion = hmac(kDate, region)
851
+ const kService = hmac(kRegion, service)
852
+ kSigning = hmac(kService, 'aws4_request')
853
+
854
+ // Limit cache size to prevent memory leaks
855
+ if (cache.size >= MAX_CACHE_SIZE) {
856
+ // Remove oldest entry (first key)
857
+ const firstKey = cache.keys().next().value
858
+ if (firstKey)
859
+ cache.delete(firstKey)
860
+ }
861
+
862
+ cache.set(cacheKey, kSigning)
863
+ }
864
+
865
+ return hmac(kSigning, stringToSign).toString('hex')
866
+ }
867
+
868
+ /**
869
+ * Calculate signature using AWS signing key derivation (async - browser compatible)
870
+ * Uses caching for signing keys to improve performance on repeated requests
871
+ */
872
+ async function calculateSignatureAsync(
873
+ secretAccessKey: string,
874
+ date: string,
875
+ region: string,
876
+ service: string,
877
+ stringToSign: string,
878
+ cache: Map<string, Buffer | Uint8Array>,
879
+ ): Promise<string> {
880
+ // Create cache key from signing parameters
881
+ const cacheKey = `${secretAccessKey}:${date}:${region}:${service}`
882
+
883
+ let kSigning = cache.get(cacheKey) as Uint8Array | undefined
884
+
885
+ if (!kSigning) {
886
+ // Derive signing key (expensive operation)
887
+ const kDate = await hmacAsync(`AWS4${secretAccessKey}`, date)
888
+ const kRegion = await hmacAsync(kDate, region)
889
+ const kService = await hmacAsync(kRegion, service)
890
+ kSigning = await hmacAsync(kService, 'aws4_request')
891
+
892
+ // Limit cache size to prevent memory leaks
893
+ if (cache.size >= MAX_CACHE_SIZE) {
894
+ // Remove oldest entry (first key)
895
+ const firstKey = cache.keys().next().value
896
+ if (firstKey)
897
+ cache.delete(firstKey)
898
+ }
899
+
900
+ cache.set(cacheKey, kSigning)
901
+ }
902
+
903
+ const signature = await hmacAsync(kSigning, stringToSign)
904
+ return bufferToHex(signature)
905
+ }
906
+
907
+ /**
908
+ * Check if an error/status code is retryable
909
+ */
910
+ function isRetryable(status: number, retryableCodes: number[]): boolean {
911
+ return retryableCodes.includes(status)
912
+ }
913
+
914
+ /**
915
+ * Calculate delay with exponential backoff and jitter
916
+ */
917
+ function calculateBackoff(attempt: number, initialDelayMs: number, maxDelayMs: number): number {
918
+ const exponentialDelay = initialDelayMs * Math.pow(2, attempt)
919
+ const jitter = Math.random() * 0.3 * exponentialDelay // 0-30% jitter
920
+ return Math.min(exponentialDelay + jitter, maxDelayMs)
921
+ }
922
+
923
+ /**
924
+ * Sleep for specified milliseconds
925
+ */
926
+ function sleep(ms: number): Promise<void> {
927
+ return new Promise(resolve => setTimeout(resolve, ms))
928
+ }
929
+
930
+ /**
931
+ * Make a signed AWS API request with automatic retry
932
+ */
933
+ export async function makeAWSRequest(
934
+ options: SignatureOptions,
935
+ retryOptions?: RetryOptions,
936
+ ): Promise<Response> {
937
+ const {
938
+ maxRetries = 3,
939
+ initialDelayMs = 100,
940
+ maxDelayMs = 5000,
941
+ retryableStatusCodes = [429, 500, 502, 503, 504],
942
+ timeoutMs = 30000,
943
+ } = retryOptions ?? {}
944
+
945
+ let lastError: Error | undefined
946
+ let lastResponse: Response | undefined
947
+
948
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
949
+ // Re-sign request on each attempt (timestamp changes)
950
+ const signedRequest = signRequest(options)
951
+
952
+ const fetchOptions: RequestInit = {
953
+ method: signedRequest.method,
954
+ headers: signedRequest.headers,
955
+ }
956
+
957
+ if (signedRequest.body) {
958
+ fetchOptions.body = signedRequest.body
959
+ }
960
+
961
+ // Add timeout support via AbortController
962
+ const controller = new AbortController()
963
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
964
+ fetchOptions.signal = controller.signal
965
+
966
+ try {
967
+ const response = await fetch(signedRequest.url, fetchOptions)
968
+ clearTimeout(timeoutId)
969
+ lastResponse = response
970
+
971
+ // Success - return immediately
972
+ if (response.ok) {
973
+ return response
974
+ }
975
+
976
+ // Check if we should retry
977
+ if (attempt < maxRetries && isRetryable(response.status, retryableStatusCodes)) {
978
+ const delay = calculateBackoff(attempt, initialDelayMs, maxDelayMs)
979
+ await sleep(delay)
980
+ continue
981
+ }
982
+
983
+ // Non-retryable error or max retries reached
984
+ const errorText = await response.text()
985
+ throw new Error(`AWS API request failed (${response.status}): ${errorText}`)
986
+ } catch (error) {
987
+ clearTimeout(timeoutId)
988
+ lastError = error as Error
989
+
990
+ // Handle timeout errors
991
+ if (error instanceof Error && error.name === 'AbortError') {
992
+ lastError = new Error(`Request timed out after ${timeoutMs}ms`)
993
+ }
994
+
995
+ // Network errors and timeouts are retryable
996
+ if (attempt < maxRetries && !(error instanceof Error && error.message.includes('AWS API request failed'))) {
997
+ const delay = calculateBackoff(attempt, initialDelayMs, maxDelayMs)
998
+ await sleep(delay)
999
+ continue
1000
+ }
1001
+
1002
+ throw lastError
1003
+ }
1004
+ }
1005
+
1006
+ // Should not reach here, but just in case
1007
+ throw lastError ?? new Error('Request failed after retries')
1008
+ }
1009
+
1010
+ /**
1011
+ * Make a signed AWS API request without retry (for backwards compatibility)
1012
+ */
1013
+ export async function makeAWSRequestOnce(
1014
+ options: SignatureOptions,
1015
+ ): Promise<Response> {
1016
+ return makeAWSRequest(options, { maxRetries: 0 })
1017
+ }
1018
+
1019
+ /**
1020
+ * Make a signed AWS API request with automatic retry (async - browser compatible)
1021
+ * Use this in browser environments where crypto.subtle is available
1022
+ */
1023
+ export async function makeAWSRequestAsync(
1024
+ options: SignatureOptions,
1025
+ retryOptions?: RetryOptions,
1026
+ ): Promise<Response> {
1027
+ const {
1028
+ maxRetries = 3,
1029
+ initialDelayMs = 100,
1030
+ maxDelayMs = 5000,
1031
+ retryableStatusCodes = [429, 500, 502, 503, 504],
1032
+ timeoutMs = 30000,
1033
+ } = retryOptions ?? {}
1034
+
1035
+ let lastError: Error | undefined
1036
+
1037
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1038
+ // Re-sign request on each attempt (timestamp changes)
1039
+ const signedRequest = await signRequestAsync(options)
1040
+
1041
+ const fetchOptions: RequestInit = {
1042
+ method: signedRequest.method,
1043
+ headers: signedRequest.headers,
1044
+ }
1045
+
1046
+ if (signedRequest.body) {
1047
+ fetchOptions.body = signedRequest.body
1048
+ }
1049
+
1050
+ // Add timeout support via AbortController
1051
+ const controller = new AbortController()
1052
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
1053
+ fetchOptions.signal = controller.signal
1054
+
1055
+ try {
1056
+ const response = await fetch(signedRequest.url, fetchOptions)
1057
+ clearTimeout(timeoutId)
1058
+
1059
+ // Success - return immediately
1060
+ if (response.ok) {
1061
+ return response
1062
+ }
1063
+
1064
+ // Check if we should retry
1065
+ if (attempt < maxRetries && isRetryable(response.status, retryableStatusCodes)) {
1066
+ const delay = calculateBackoff(attempt, initialDelayMs, maxDelayMs)
1067
+ await sleep(delay)
1068
+ continue
1069
+ }
1070
+
1071
+ // Non-retryable error or max retries reached
1072
+ const errorText = await response.text()
1073
+ throw new Error(`AWS API request failed (${response.status}): ${errorText}`)
1074
+ } catch (error) {
1075
+ clearTimeout(timeoutId)
1076
+ lastError = error as Error
1077
+
1078
+ // Handle timeout errors
1079
+ if (error instanceof Error && error.name === 'AbortError') {
1080
+ lastError = new Error(`Request timed out after ${timeoutMs}ms`)
1081
+ }
1082
+
1083
+ // Network errors and timeouts are retryable
1084
+ if (attempt < maxRetries && !(error instanceof Error && error.message.includes('AWS API request failed'))) {
1085
+ const delay = calculateBackoff(attempt, initialDelayMs, maxDelayMs)
1086
+ await sleep(delay)
1087
+ continue
1088
+ }
1089
+
1090
+ throw lastError
1091
+ }
1092
+ }
1093
+
1094
+ // Should not reach here, but just in case
1095
+ throw lastError ?? new Error('Request failed after retries')
1096
+ }
1097
+
1098
+ /**
1099
+ * Parse XML response from AWS
1100
+ */
1101
+ export async function parseXMLResponse<T = any>(response: Response): Promise<T> {
1102
+ const text = await response.text()
1103
+
1104
+ // Simple XML parsing (for production, use a proper XML parser)
1105
+ // This is a basic implementation for demonstration
1106
+ const result: any = {}
1107
+
1108
+ // Extract key-value pairs from XML
1109
+ const regex = /<(\w+)>([^<]+)<\/\1>/g
1110
+ let match
1111
+ while ((match = regex.exec(text)) !== null) {
1112
+ const [, key, value] = match
1113
+ result[key] = value
1114
+ }
1115
+
1116
+ return result as T
1117
+ }
1118
+
1119
+ /**
1120
+ * Parse JSON response from AWS
1121
+ */
1122
+ export async function parseJSONResponse<T = any>(response: Response): Promise<T> {
1123
+ return await response.json() as T
1124
+ }
1125
+
1126
+ /**
1127
+ * Clear the internal signing key cache
1128
+ * Call this when credentials change or for testing
1129
+ */
1130
+ export function clearSigningKeyCache(): void {
1131
+ signingKeyCache.clear()
1132
+ }
1133
+
1134
+ /**
1135
+ * Get current cache size (for diagnostics)
1136
+ */
1137
+ export function getSigningKeyCacheSize(): number {
1138
+ return signingKeyCache.size
1139
+ }
1140
+
1141
+ /**
1142
+ * Check if Node.js crypto is available (for sync operations)
1143
+ * Returns true in Node.js/Bun, false in browser
1144
+ */
1145
+ export function isNodeCryptoAvailable(): boolean {
1146
+ return nodeCrypto !== undefined
1147
+ }
1148
+
1149
+ /**
1150
+ * Check if Web Crypto API is available (for async operations)
1151
+ * Returns true in modern browsers and Node.js 15+
1152
+ */
1153
+ export function isWebCryptoAvailable(): boolean {
1154
+ return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'
1155
+ }