@stacksjs/ts-cloud-core 0.1.3 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -13
- package/package.json +12 -3
- package/src/advanced-features.test.ts +0 -465
- package/src/aws/cloudformation.ts +0 -421
- package/src/aws/cloudfront.ts +0 -158
- package/src/aws/credentials.test.ts +0 -132
- package/src/aws/credentials.ts +0 -545
- package/src/aws/index.ts +0 -87
- package/src/aws/s3.test.ts +0 -188
- package/src/aws/s3.ts +0 -1088
- package/src/aws/signature.test.ts +0 -670
- package/src/aws/signature.ts +0 -1155
- package/src/backup/disaster-recovery.test.ts +0 -726
- package/src/backup/disaster-recovery.ts +0 -500
- package/src/backup/index.ts +0 -34
- package/src/backup/manager.test.ts +0 -498
- package/src/backup/manager.ts +0 -432
- package/src/cicd/circleci.ts +0 -430
- package/src/cicd/github-actions.ts +0 -424
- package/src/cicd/gitlab-ci.ts +0 -255
- package/src/cicd/index.ts +0 -8
- package/src/cli/history.ts +0 -396
- package/src/cli/index.ts +0 -10
- package/src/cli/progress.ts +0 -458
- package/src/cli/repl.ts +0 -454
- package/src/cli/suggestions.ts +0 -327
- package/src/cli/table.test.ts +0 -319
- package/src/cli/table.ts +0 -332
- package/src/cloudformation/builder.test.ts +0 -327
- package/src/cloudformation/builder.ts +0 -378
- package/src/cloudformation/builders/api-gateway.ts +0 -449
- package/src/cloudformation/builders/cache.ts +0 -334
- package/src/cloudformation/builders/cdn.ts +0 -278
- package/src/cloudformation/builders/compute.ts +0 -485
- package/src/cloudformation/builders/database.ts +0 -392
- package/src/cloudformation/builders/functions.ts +0 -343
- package/src/cloudformation/builders/messaging.ts +0 -140
- package/src/cloudformation/builders/monitoring.ts +0 -300
- package/src/cloudformation/builders/network.ts +0 -264
- package/src/cloudformation/builders/queue.ts +0 -147
- package/src/cloudformation/builders/security.ts +0 -399
- package/src/cloudformation/builders/storage.ts +0 -285
- package/src/cloudformation/index.ts +0 -30
- package/src/cloudformation/types.ts +0 -173
- package/src/compliance/aws-config.ts +0 -543
- package/src/compliance/cloudtrail.ts +0 -376
- package/src/compliance/compliance.test.ts +0 -423
- package/src/compliance/guardduty.ts +0 -446
- package/src/compliance/index.ts +0 -66
- package/src/compliance/security-hub.ts +0 -456
- package/src/containers/build-optimization.ts +0 -416
- package/src/containers/containers.test.ts +0 -508
- package/src/containers/image-scanning.ts +0 -360
- package/src/containers/index.ts +0 -9
- package/src/containers/registry.ts +0 -293
- package/src/containers/service-mesh.ts +0 -520
- package/src/database/database.test.ts +0 -762
- package/src/database/index.ts +0 -9
- package/src/database/migrations.ts +0 -444
- package/src/database/performance.ts +0 -528
- package/src/database/replicas.ts +0 -534
- package/src/database/users.ts +0 -494
- package/src/dependency-graph.ts +0 -143
- package/src/deployment/ab-testing.ts +0 -582
- package/src/deployment/blue-green.ts +0 -452
- package/src/deployment/canary.ts +0 -500
- package/src/deployment/deployment.test.ts +0 -526
- package/src/deployment/index.ts +0 -61
- package/src/deployment/progressive.ts +0 -62
- package/src/dns/dns.test.ts +0 -641
- package/src/dns/dnssec.ts +0 -315
- package/src/dns/index.ts +0 -8
- package/src/dns/resolver.ts +0 -496
- package/src/dns/routing.ts +0 -593
- package/src/email/advanced/analytics.ts +0 -445
- package/src/email/advanced/index.ts +0 -11
- package/src/email/advanced/rules.ts +0 -465
- package/src/email/advanced/scheduling.ts +0 -352
- package/src/email/advanced/search.ts +0 -412
- package/src/email/advanced/shared-mailboxes.ts +0 -404
- package/src/email/advanced/templates.ts +0 -455
- package/src/email/advanced/threading.ts +0 -281
- package/src/email/analytics.ts +0 -467
- package/src/email/bounce-handling.ts +0 -425
- package/src/email/email.test.ts +0 -431
- package/src/email/handlers/__tests__/inbound.test.ts +0 -38
- package/src/email/handlers/__tests__/outbound.test.ts +0 -37
- package/src/email/handlers/converter.ts +0 -227
- package/src/email/handlers/feedback.ts +0 -228
- package/src/email/handlers/inbound.ts +0 -169
- package/src/email/handlers/outbound.ts +0 -178
- package/src/email/index.ts +0 -15
- package/src/email/reputation.ts +0 -303
- package/src/email/templates.ts +0 -352
- package/src/errors/index.test.ts +0 -434
- package/src/errors/index.ts +0 -416
- package/src/health-checks/index.ts +0 -40
- package/src/index.ts +0 -360
- package/src/intrinsic-functions.ts +0 -118
- package/src/lambda/concurrency.ts +0 -330
- package/src/lambda/destinations.ts +0 -345
- package/src/lambda/dlq.ts +0 -425
- package/src/lambda/index.ts +0 -11
- package/src/lambda/lambda.test.ts +0 -840
- package/src/lambda/layers.ts +0 -263
- package/src/lambda/versions.ts +0 -376
- package/src/lambda/vpc.ts +0 -399
- package/src/local/config.ts +0 -114
- package/src/local/index.ts +0 -6
- package/src/local/mock-aws.ts +0 -351
- package/src/modules/ai.ts +0 -340
- package/src/modules/api.ts +0 -478
- package/src/modules/auth.ts +0 -805
- package/src/modules/cache.ts +0 -417
- package/src/modules/cdn.ts +0 -1062
- package/src/modules/communication.ts +0 -1094
- package/src/modules/compute.ts +0 -3348
- package/src/modules/database.ts +0 -554
- package/src/modules/deployment.ts +0 -1079
- package/src/modules/dns.ts +0 -337
- package/src/modules/email.ts +0 -1538
- package/src/modules/filesystem.ts +0 -515
- package/src/modules/index.ts +0 -32
- package/src/modules/messaging.ts +0 -486
- package/src/modules/monitoring.ts +0 -2086
- package/src/modules/network.ts +0 -664
- package/src/modules/parameter-store.ts +0 -325
- package/src/modules/permissions.ts +0 -1081
- package/src/modules/phone.ts +0 -494
- package/src/modules/queue.ts +0 -1260
- package/src/modules/redirects.ts +0 -464
- package/src/modules/registry.ts +0 -699
- package/src/modules/search.ts +0 -401
- package/src/modules/secrets.ts +0 -416
- package/src/modules/security.ts +0 -731
- package/src/modules/sms.ts +0 -389
- package/src/modules/storage.ts +0 -1120
- package/src/modules/workflow.ts +0 -680
- package/src/multi-account/config.ts +0 -521
- package/src/multi-account/index.ts +0 -7
- package/src/multi-account/manager.ts +0 -427
- package/src/multi-region/cross-region.ts +0 -410
- package/src/multi-region/index.ts +0 -8
- package/src/multi-region/manager.ts +0 -483
- package/src/multi-region/regions.ts +0 -435
- package/src/network-security/index.ts +0 -48
- package/src/observability/index.ts +0 -9
- package/src/observability/logs.ts +0 -522
- package/src/observability/metrics.ts +0 -460
- package/src/observability/observability.test.ts +0 -782
- package/src/observability/synthetics.ts +0 -568
- package/src/observability/xray.ts +0 -358
- package/src/phone/advanced/analytics.ts +0 -349
- package/src/phone/advanced/callbacks.ts +0 -428
- package/src/phone/advanced/index.ts +0 -8
- package/src/phone/advanced/ivr-builder.ts +0 -504
- package/src/phone/advanced/recording.ts +0 -310
- package/src/phone/handlers/__tests__/incoming-call.test.ts +0 -40
- package/src/phone/handlers/incoming-call.ts +0 -117
- package/src/phone/handlers/missed-call.ts +0 -116
- package/src/phone/handlers/voicemail.ts +0 -179
- package/src/phone/index.ts +0 -9
- package/src/presets/api-backend.ts +0 -134
- package/src/presets/data-pipeline.ts +0 -204
- package/src/presets/extend.test.ts +0 -295
- package/src/presets/extend.ts +0 -297
- package/src/presets/fullstack-app.ts +0 -144
- package/src/presets/index.ts +0 -27
- package/src/presets/jamstack.ts +0 -135
- package/src/presets/microservices.ts +0 -167
- package/src/presets/ml-api.ts +0 -208
- package/src/presets/nodejs-server.ts +0 -104
- package/src/presets/nodejs-serverless.ts +0 -114
- package/src/presets/realtime-app.ts +0 -184
- package/src/presets/static-site.ts +0 -64
- package/src/presets/traditional-web-app.ts +0 -339
- package/src/presets/wordpress.ts +0 -138
- package/src/preview/github.test.ts +0 -249
- package/src/preview/github.ts +0 -297
- package/src/preview/index.ts +0 -37
- package/src/preview/manager.test.ts +0 -440
- package/src/preview/manager.ts +0 -326
- package/src/preview/notifications.test.ts +0 -582
- package/src/preview/notifications.ts +0 -341
- package/src/queue/batch-processing.ts +0 -402
- package/src/queue/dlq-monitoring.ts +0 -402
- package/src/queue/fifo.ts +0 -342
- package/src/queue/index.ts +0 -9
- package/src/queue/management.ts +0 -428
- package/src/queue/queue.test.ts +0 -429
- package/src/resource-mgmt/index.ts +0 -39
- package/src/resource-naming.ts +0 -62
- package/src/s3/index.ts +0 -523
- package/src/schema/cloud-config.schema.json +0 -554
- package/src/schema/index.ts +0 -68
- package/src/security/certificate-manager.ts +0 -492
- package/src/security/index.ts +0 -9
- package/src/security/scanning.ts +0 -545
- package/src/security/secrets-manager.ts +0 -476
- package/src/security/secrets-rotation.ts +0 -456
- package/src/security/security.test.ts +0 -738
- package/src/sms/advanced/ab-testing.ts +0 -389
- package/src/sms/advanced/analytics.ts +0 -336
- package/src/sms/advanced/campaigns.ts +0 -523
- package/src/sms/advanced/chatbot.ts +0 -224
- package/src/sms/advanced/index.ts +0 -10
- package/src/sms/advanced/link-tracking.ts +0 -248
- package/src/sms/advanced/mms.ts +0 -308
- package/src/sms/handlers/__tests__/send.test.ts +0 -40
- package/src/sms/handlers/delivery-status.ts +0 -133
- package/src/sms/handlers/receive.ts +0 -162
- package/src/sms/handlers/send.ts +0 -174
- package/src/sms/index.ts +0 -9
- package/src/stack-diff.ts +0 -389
- package/src/static-site/index.ts +0 -85
- package/src/template-builder.ts +0 -110
- package/src/template-validator.ts +0 -574
- package/src/utils/cache.ts +0 -291
- package/src/utils/diff.ts +0 -269
- package/src/utils/hash.ts +0 -227
- package/src/utils/index.ts +0 -8
- package/src/utils/parallel.ts +0 -294
- package/src/validators/credentials.test.ts +0 -274
- package/src/validators/credentials.ts +0 -233
- package/src/validators/quotas.test.ts +0 -434
- package/src/validators/quotas.ts +0 -217
- package/test/ai.test.ts +0 -327
- package/test/api.test.ts +0 -511
- package/test/auth.test.ts +0 -632
- package/test/cache.test.ts +0 -406
- package/test/cdn.test.ts +0 -247
- package/test/compute.test.ts +0 -861
- package/test/database.test.ts +0 -523
- package/test/deployment.test.ts +0 -499
- package/test/dns.test.ts +0 -270
- package/test/email.test.ts +0 -439
- package/test/filesystem.test.ts +0 -382
- package/test/integration.test.ts +0 -350
- package/test/messaging.test.ts +0 -514
- package/test/monitoring.test.ts +0 -634
- package/test/network.test.ts +0 -425
- package/test/permissions.test.ts +0 -488
- package/test/queue.test.ts +0 -484
- package/test/registry.test.ts +0 -306
- package/test/security.test.ts +0 -462
- package/test/storage.test.ts +0 -463
- package/test/template-validator.test.ts +0 -559
- package/test/workflow.test.ts +0 -592
- package/tsconfig.json +0 -16
- package/tsconfig.tsbuildinfo +0 -1
package/src/aws/s3.ts
DELETED
|
@@ -1,1088 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* S3 High-Level API
|
|
3
|
-
*
|
|
4
|
-
* Simple, intuitive interface for S3 operations:
|
|
5
|
-
* - get, put, delete, list, head, copy
|
|
6
|
-
* - Streaming uploads/downloads
|
|
7
|
-
* - Multipart uploads for large files
|
|
8
|
-
* - Automatic content type detection
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { signRequest, signRequestAsync, createPresignedUrl, createPresignedUrlAsync, type RetryOptions } from './signature'
|
|
12
|
-
import { getCredentials, type AWSCredentials, type CredentialProviderOptions } from './credentials'
|
|
13
|
-
|
|
14
|
-
export interface S3ClientOptions {
|
|
15
|
-
/** AWS region (default: 'us-east-1') */
|
|
16
|
-
region?: string
|
|
17
|
-
/** Custom endpoint URL (for MinIO, LocalStack, etc.) */
|
|
18
|
-
endpoint?: string
|
|
19
|
-
/** Force path-style URLs instead of virtual-hosted-style */
|
|
20
|
-
forcePathStyle?: boolean
|
|
21
|
-
/** AWS credentials (if not provided, uses credential chain) */
|
|
22
|
-
credentials?: AWSCredentials
|
|
23
|
-
/** Credential provider options */
|
|
24
|
-
credentialOptions?: CredentialProviderOptions
|
|
25
|
-
/** Retry options for requests */
|
|
26
|
-
retryOptions?: RetryOptions
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface GetObjectOptions {
|
|
30
|
-
/** Byte range to fetch (e.g., 'bytes=0-1023') */
|
|
31
|
-
range?: string
|
|
32
|
-
/** Only return if modified since this date */
|
|
33
|
-
ifModifiedSince?: Date
|
|
34
|
-
/** Only return if ETag matches */
|
|
35
|
-
ifMatch?: string
|
|
36
|
-
/** Only return if ETag doesn't match */
|
|
37
|
-
ifNoneMatch?: string
|
|
38
|
-
/** Response content-type override */
|
|
39
|
-
responseContentType?: string
|
|
40
|
-
/** Response content-disposition override */
|
|
41
|
-
responseContentDisposition?: string
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface PutObjectOptions {
|
|
45
|
-
/** Content type (auto-detected if not provided) */
|
|
46
|
-
contentType?: string
|
|
47
|
-
/** Content encoding (e.g., 'gzip') */
|
|
48
|
-
contentEncoding?: string
|
|
49
|
-
/** Cache-Control header */
|
|
50
|
-
cacheControl?: string
|
|
51
|
-
/** Content-Disposition header */
|
|
52
|
-
contentDisposition?: string
|
|
53
|
-
/** Custom metadata (x-amz-meta-*) */
|
|
54
|
-
metadata?: Record<string, string>
|
|
55
|
-
/** Storage class (STANDARD, REDUCED_REDUNDANCY, GLACIER, etc.) */
|
|
56
|
-
storageClass?: string
|
|
57
|
-
/** Server-side encryption (AES256, aws:kms) */
|
|
58
|
-
serverSideEncryption?: string
|
|
59
|
-
/** ACL (private, public-read, etc.) */
|
|
60
|
-
acl?: string
|
|
61
|
-
/** Tagging (URL-encoded key=value pairs) */
|
|
62
|
-
tagging?: string
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface ListObjectsOptions {
|
|
66
|
-
/** Prefix to filter objects */
|
|
67
|
-
prefix?: string
|
|
68
|
-
/** Delimiter for grouping (usually '/') */
|
|
69
|
-
delimiter?: string
|
|
70
|
-
/** Maximum number of keys to return (default: 1000) */
|
|
71
|
-
maxKeys?: number
|
|
72
|
-
/** Continuation token for pagination */
|
|
73
|
-
continuationToken?: string
|
|
74
|
-
/** Start listing after this key */
|
|
75
|
-
startAfter?: string
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface ListObjectsResult {
|
|
79
|
-
contents: S3Object[]
|
|
80
|
-
commonPrefixes: string[]
|
|
81
|
-
isTruncated: boolean
|
|
82
|
-
continuationToken?: string
|
|
83
|
-
nextContinuationToken?: string
|
|
84
|
-
keyCount: number
|
|
85
|
-
maxKeys: number
|
|
86
|
-
prefix?: string
|
|
87
|
-
delimiter?: string
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
export interface S3Object {
|
|
91
|
-
key: string
|
|
92
|
-
lastModified: Date
|
|
93
|
-
etag: string
|
|
94
|
-
size: number
|
|
95
|
-
storageClass: string
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface HeadObjectResult {
|
|
99
|
-
contentLength: number
|
|
100
|
-
contentType: string
|
|
101
|
-
etag: string
|
|
102
|
-
lastModified: Date
|
|
103
|
-
metadata: Record<string, string>
|
|
104
|
-
storageClass?: string
|
|
105
|
-
serverSideEncryption?: string
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export interface CopyObjectOptions {
|
|
109
|
-
/** Metadata directive (COPY or REPLACE) */
|
|
110
|
-
metadataDirective?: 'COPY' | 'REPLACE'
|
|
111
|
-
/** New metadata (only used if metadataDirective is REPLACE) */
|
|
112
|
-
metadata?: Record<string, string>
|
|
113
|
-
/** Content type (only used if metadataDirective is REPLACE) */
|
|
114
|
-
contentType?: string
|
|
115
|
-
/** Storage class for the copy */
|
|
116
|
-
storageClass?: string
|
|
117
|
-
/** ACL for the copy */
|
|
118
|
-
acl?: string
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export interface MultipartUploadOptions extends PutObjectOptions {
|
|
122
|
-
/** Part size in bytes (default: 5MB, minimum: 5MB) */
|
|
123
|
-
partSize?: number
|
|
124
|
-
/** Maximum concurrent uploads (default: 4) */
|
|
125
|
-
concurrency?: number
|
|
126
|
-
/** Progress callback */
|
|
127
|
-
onProgress?: (progress: MultipartProgress) => void
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export interface MultipartProgress {
|
|
131
|
-
loaded: number
|
|
132
|
-
total: number
|
|
133
|
-
part: number
|
|
134
|
-
totalParts: number
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export interface PresignedUrlOptions {
|
|
138
|
-
/** Expiration time in seconds (default: 3600 = 1 hour) */
|
|
139
|
-
expiresIn?: number
|
|
140
|
-
/** HTTP method (default: 'GET') */
|
|
141
|
-
method?: string
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Minimum part size for multipart upload (5MB)
|
|
145
|
-
const MIN_PART_SIZE = 5 * 1024 * 1024
|
|
146
|
-
// Default part size (5MB)
|
|
147
|
-
const DEFAULT_PART_SIZE = 5 * 1024 * 1024
|
|
148
|
-
// Maximum parts in a multipart upload
|
|
149
|
-
const MAX_PARTS = 10000
|
|
150
|
-
// Threshold for using multipart upload (5MB)
|
|
151
|
-
const MULTIPART_THRESHOLD = 5 * 1024 * 1024
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* S3 Client for high-level S3 operations
|
|
155
|
-
*/
|
|
156
|
-
export class S3Client {
|
|
157
|
-
private region: string
|
|
158
|
-
private endpoint: string
|
|
159
|
-
private forcePathStyle: boolean
|
|
160
|
-
private credentials?: AWSCredentials
|
|
161
|
-
private credentialOptions?: CredentialProviderOptions
|
|
162
|
-
private retryOptions: RetryOptions
|
|
163
|
-
|
|
164
|
-
constructor(options: S3ClientOptions = {}) {
|
|
165
|
-
this.region = options.region || process.env.AWS_REGION || 'us-east-1'
|
|
166
|
-
this.endpoint = options.endpoint || `https://s3.${this.region}.amazonaws.com`
|
|
167
|
-
this.forcePathStyle = options.forcePathStyle || false
|
|
168
|
-
this.credentials = options.credentials
|
|
169
|
-
this.credentialOptions = options.credentialOptions
|
|
170
|
-
this.retryOptions = options.retryOptions || {}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Get credentials (cached or from provider chain)
|
|
175
|
-
*/
|
|
176
|
-
private async getCredentials(): Promise<AWSCredentials> {
|
|
177
|
-
if (this.credentials) {
|
|
178
|
-
return this.credentials
|
|
179
|
-
}
|
|
180
|
-
return getCredentials(this.credentialOptions)
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Build S3 URL for a bucket/key
|
|
185
|
-
*/
|
|
186
|
-
private buildUrl(bucket: string, key?: string): string {
|
|
187
|
-
const encodedKey = key ? encodeURIComponent(key).replace(/%2F/g, '/') : ''
|
|
188
|
-
|
|
189
|
-
if (this.forcePathStyle) {
|
|
190
|
-
return key
|
|
191
|
-
? `${this.endpoint}/${bucket}/${encodedKey}`
|
|
192
|
-
: `${this.endpoint}/${bucket}`
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Virtual-hosted style
|
|
196
|
-
const url = new URL(this.endpoint)
|
|
197
|
-
url.hostname = `${bucket}.${url.hostname}`
|
|
198
|
-
return key ? `${url.origin}/${encodedKey}` : url.origin
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Get an object from S3
|
|
203
|
-
*/
|
|
204
|
-
async get(bucket: string, key: string, options: GetObjectOptions = {}): Promise<Response> {
|
|
205
|
-
const credentials = await this.getCredentials()
|
|
206
|
-
const url = this.buildUrl(bucket, key)
|
|
207
|
-
|
|
208
|
-
const headers: Record<string, string> = {}
|
|
209
|
-
if (options.range) headers['Range'] = options.range
|
|
210
|
-
if (options.ifModifiedSince) headers['If-Modified-Since'] = options.ifModifiedSince.toUTCString()
|
|
211
|
-
if (options.ifMatch) headers['If-Match'] = options.ifMatch
|
|
212
|
-
if (options.ifNoneMatch) headers['If-None-Match'] = options.ifNoneMatch
|
|
213
|
-
|
|
214
|
-
// Add response overrides as query params
|
|
215
|
-
const urlObj = new URL(url)
|
|
216
|
-
if (options.responseContentType) urlObj.searchParams.set('response-content-type', options.responseContentType)
|
|
217
|
-
if (options.responseContentDisposition) urlObj.searchParams.set('response-content-disposition', options.responseContentDisposition)
|
|
218
|
-
|
|
219
|
-
const signed = signRequest({
|
|
220
|
-
method: 'GET',
|
|
221
|
-
url: urlObj.toString(),
|
|
222
|
-
headers,
|
|
223
|
-
...credentials,
|
|
224
|
-
service: 's3',
|
|
225
|
-
region: this.region,
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
const response = await fetch(signed.url, {
|
|
229
|
-
method: signed.method,
|
|
230
|
-
headers: signed.headers,
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
if (!response.ok && response.status !== 304) {
|
|
234
|
-
const error = await response.text()
|
|
235
|
-
throw new S3Error(`Failed to get object: ${error}`, response.status, bucket, key)
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return response
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Get object as text
|
|
243
|
-
*/
|
|
244
|
-
async getText(bucket: string, key: string, options: GetObjectOptions = {}): Promise<string> {
|
|
245
|
-
const response = await this.get(bucket, key, options)
|
|
246
|
-
return response.text()
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Get object as JSON
|
|
251
|
-
*/
|
|
252
|
-
async getJSON<T = unknown>(bucket: string, key: string, options: GetObjectOptions = {}): Promise<T> {
|
|
253
|
-
const response = await this.get(bucket, key, options)
|
|
254
|
-
return response.json() as Promise<T>
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Get object as ArrayBuffer
|
|
259
|
-
*/
|
|
260
|
-
async getBuffer(bucket: string, key: string, options: GetObjectOptions = {}): Promise<ArrayBuffer> {
|
|
261
|
-
const response = await this.get(bucket, key, options)
|
|
262
|
-
return response.arrayBuffer()
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Put an object to S3
|
|
267
|
-
* Automatically uses multipart upload for large files (>5MB)
|
|
268
|
-
*/
|
|
269
|
-
async put(
|
|
270
|
-
bucket: string,
|
|
271
|
-
key: string,
|
|
272
|
-
body: string | ArrayBuffer | Uint8Array | Blob | ReadableStream,
|
|
273
|
-
options: PutObjectOptions = {},
|
|
274
|
-
): Promise<{ etag: string }> {
|
|
275
|
-
// Get body size
|
|
276
|
-
let size: number
|
|
277
|
-
let bodyToUpload: string | ArrayBuffer | Uint8Array | Blob
|
|
278
|
-
|
|
279
|
-
if (typeof body === 'string') {
|
|
280
|
-
size = new TextEncoder().encode(body).length
|
|
281
|
-
bodyToUpload = body
|
|
282
|
-
} else if (body instanceof ArrayBuffer) {
|
|
283
|
-
size = body.byteLength
|
|
284
|
-
bodyToUpload = body
|
|
285
|
-
} else if (body instanceof Uint8Array) {
|
|
286
|
-
size = body.byteLength
|
|
287
|
-
bodyToUpload = body
|
|
288
|
-
} else if (body instanceof Blob) {
|
|
289
|
-
size = body.size
|
|
290
|
-
bodyToUpload = body
|
|
291
|
-
} else {
|
|
292
|
-
// ReadableStream - use multipart upload
|
|
293
|
-
return this.uploadMultipart(bucket, key, body, {
|
|
294
|
-
...options,
|
|
295
|
-
partSize: DEFAULT_PART_SIZE,
|
|
296
|
-
})
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Use multipart for large files
|
|
300
|
-
if (size > MULTIPART_THRESHOLD) {
|
|
301
|
-
const stream = bodyToBlob(bodyToUpload).stream()
|
|
302
|
-
return this.uploadMultipart(bucket, key, stream, {
|
|
303
|
-
...options,
|
|
304
|
-
partSize: DEFAULT_PART_SIZE,
|
|
305
|
-
})
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Simple upload for small files
|
|
309
|
-
return this.putSimple(bucket, key, bodyToUpload, size, options)
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Simple PUT for small files
|
|
314
|
-
*/
|
|
315
|
-
private async putSimple(
|
|
316
|
-
bucket: string,
|
|
317
|
-
key: string,
|
|
318
|
-
body: string | ArrayBuffer | Uint8Array | Blob,
|
|
319
|
-
size: number,
|
|
320
|
-
options: PutObjectOptions,
|
|
321
|
-
): Promise<{ etag: string }> {
|
|
322
|
-
const credentials = await this.getCredentials()
|
|
323
|
-
const url = this.buildUrl(bucket, key)
|
|
324
|
-
|
|
325
|
-
const headers: Record<string, string> = {
|
|
326
|
-
'Content-Length': String(size),
|
|
327
|
-
'Content-Type': options.contentType || detectContentType(key),
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
if (options.contentEncoding) headers['Content-Encoding'] = options.contentEncoding
|
|
331
|
-
if (options.cacheControl) headers['Cache-Control'] = options.cacheControl
|
|
332
|
-
if (options.contentDisposition) headers['Content-Disposition'] = options.contentDisposition
|
|
333
|
-
if (options.storageClass) headers['x-amz-storage-class'] = options.storageClass
|
|
334
|
-
if (options.serverSideEncryption) headers['x-amz-server-side-encryption'] = options.serverSideEncryption
|
|
335
|
-
if (options.acl) headers['x-amz-acl'] = options.acl
|
|
336
|
-
if (options.tagging) headers['x-amz-tagging'] = options.tagging
|
|
337
|
-
|
|
338
|
-
// Add custom metadata
|
|
339
|
-
if (options.metadata) {
|
|
340
|
-
for (const [k, v] of Object.entries(options.metadata)) {
|
|
341
|
-
headers[`x-amz-meta-${k.toLowerCase()}`] = v
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Convert body to string for signing
|
|
346
|
-
let bodyString: string
|
|
347
|
-
if (typeof body === 'string') {
|
|
348
|
-
bodyString = body
|
|
349
|
-
} else if (body instanceof Blob) {
|
|
350
|
-
bodyString = await body.text()
|
|
351
|
-
} else {
|
|
352
|
-
bodyString = new TextDecoder().decode(body instanceof ArrayBuffer ? new Uint8Array(body) : body)
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const signed = signRequest({
|
|
356
|
-
method: 'PUT',
|
|
357
|
-
url,
|
|
358
|
-
headers,
|
|
359
|
-
body: bodyString,
|
|
360
|
-
...credentials,
|
|
361
|
-
service: 's3',
|
|
362
|
-
region: this.region,
|
|
363
|
-
})
|
|
364
|
-
|
|
365
|
-
const response = await fetch(signed.url, {
|
|
366
|
-
method: signed.method,
|
|
367
|
-
headers: signed.headers,
|
|
368
|
-
body,
|
|
369
|
-
})
|
|
370
|
-
|
|
371
|
-
if (!response.ok) {
|
|
372
|
-
const error = await response.text()
|
|
373
|
-
throw new S3Error(`Failed to put object: ${error}`, response.status, bucket, key)
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
const etag = response.headers.get('ETag') || ''
|
|
377
|
-
return { etag: etag.replace(/"/g, '') }
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Delete an object from S3
|
|
382
|
-
*/
|
|
383
|
-
async delete(bucket: string, key: string): Promise<void> {
|
|
384
|
-
const credentials = await this.getCredentials()
|
|
385
|
-
const url = this.buildUrl(bucket, key)
|
|
386
|
-
|
|
387
|
-
const signed = signRequest({
|
|
388
|
-
method: 'DELETE',
|
|
389
|
-
url,
|
|
390
|
-
...credentials,
|
|
391
|
-
service: 's3',
|
|
392
|
-
region: this.region,
|
|
393
|
-
})
|
|
394
|
-
|
|
395
|
-
const response = await fetch(signed.url, {
|
|
396
|
-
method: signed.method,
|
|
397
|
-
headers: signed.headers,
|
|
398
|
-
})
|
|
399
|
-
|
|
400
|
-
if (!response.ok && response.status !== 204) {
|
|
401
|
-
const error = await response.text()
|
|
402
|
-
throw new S3Error(`Failed to delete object: ${error}`, response.status, bucket, key)
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* Delete multiple objects from S3
|
|
408
|
-
*/
|
|
409
|
-
async deleteMany(bucket: string, keys: string[]): Promise<{ deleted: string[], errors: Array<{ key: string, error: string }> }> {
|
|
410
|
-
const credentials = await this.getCredentials()
|
|
411
|
-
const url = `${this.buildUrl(bucket)}?delete`
|
|
412
|
-
|
|
413
|
-
// Build XML body
|
|
414
|
-
const objects = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('')
|
|
415
|
-
const body = `<?xml version="1.0" encoding="UTF-8"?><Delete><Quiet>false</Quiet>${objects}</Delete>`
|
|
416
|
-
|
|
417
|
-
const signed = signRequest({
|
|
418
|
-
method: 'POST',
|
|
419
|
-
url,
|
|
420
|
-
headers: {
|
|
421
|
-
'Content-Type': 'application/xml',
|
|
422
|
-
},
|
|
423
|
-
body,
|
|
424
|
-
...credentials,
|
|
425
|
-
service: 's3',
|
|
426
|
-
region: this.region,
|
|
427
|
-
})
|
|
428
|
-
|
|
429
|
-
const response = await fetch(signed.url, {
|
|
430
|
-
method: signed.method,
|
|
431
|
-
headers: signed.headers,
|
|
432
|
-
body,
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
if (!response.ok) {
|
|
436
|
-
const error = await response.text()
|
|
437
|
-
throw new S3Error(`Failed to delete objects: ${error}`, response.status, bucket)
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const xml = await response.text()
|
|
441
|
-
const deleted: string[] = []
|
|
442
|
-
const errors: Array<{ key: string, error: string }> = []
|
|
443
|
-
|
|
444
|
-
// Parse deleted keys
|
|
445
|
-
const deletedRegex = /<Deleted><Key>([^<]+)<\/Key>/g
|
|
446
|
-
let match
|
|
447
|
-
while ((match = deletedRegex.exec(xml)) !== null) {
|
|
448
|
-
deleted.push(match[1])
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// Parse errors
|
|
452
|
-
const errorRegex = /<Error><Key>([^<]+)<\/Key><Code>([^<]+)<\/Code><Message>([^<]+)<\/Message>/g
|
|
453
|
-
while ((match = errorRegex.exec(xml)) !== null) {
|
|
454
|
-
errors.push({ key: match[1], error: `${match[2]}: ${match[3]}` })
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
return { deleted, errors }
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* List objects in a bucket
|
|
462
|
-
*/
|
|
463
|
-
async list(bucket: string, options: ListObjectsOptions = {}): Promise<ListObjectsResult> {
|
|
464
|
-
const credentials = await this.getCredentials()
|
|
465
|
-
const urlObj = new URL(this.buildUrl(bucket))
|
|
466
|
-
urlObj.searchParams.set('list-type', '2')
|
|
467
|
-
|
|
468
|
-
if (options.prefix) urlObj.searchParams.set('prefix', options.prefix)
|
|
469
|
-
if (options.delimiter) urlObj.searchParams.set('delimiter', options.delimiter)
|
|
470
|
-
if (options.maxKeys) urlObj.searchParams.set('max-keys', String(options.maxKeys))
|
|
471
|
-
if (options.continuationToken) urlObj.searchParams.set('continuation-token', options.continuationToken)
|
|
472
|
-
if (options.startAfter) urlObj.searchParams.set('start-after', options.startAfter)
|
|
473
|
-
|
|
474
|
-
const signed = signRequest({
|
|
475
|
-
method: 'GET',
|
|
476
|
-
url: urlObj.toString(),
|
|
477
|
-
...credentials,
|
|
478
|
-
service: 's3',
|
|
479
|
-
region: this.region,
|
|
480
|
-
})
|
|
481
|
-
|
|
482
|
-
const response = await fetch(signed.url, {
|
|
483
|
-
method: signed.method,
|
|
484
|
-
headers: signed.headers,
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
if (!response.ok) {
|
|
488
|
-
const error = await response.text()
|
|
489
|
-
throw new S3Error(`Failed to list objects: ${error}`, response.status, bucket)
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
const xml = await response.text()
|
|
493
|
-
return parseListObjectsResponse(xml)
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* List all objects with automatic pagination
|
|
498
|
-
*/
|
|
499
|
-
async *listAll(bucket: string, options: Omit<ListObjectsOptions, 'continuationToken'> = {}): AsyncGenerator<S3Object> {
|
|
500
|
-
let continuationToken: string | undefined
|
|
501
|
-
|
|
502
|
-
do {
|
|
503
|
-
const result = await this.list(bucket, { ...options, continuationToken })
|
|
504
|
-
|
|
505
|
-
for (const obj of result.contents) {
|
|
506
|
-
yield obj
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
continuationToken = result.nextContinuationToken
|
|
510
|
-
} while (continuationToken)
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Check if an object exists and get its metadata
|
|
515
|
-
*/
|
|
516
|
-
async head(bucket: string, key: string): Promise<HeadObjectResult | null> {
|
|
517
|
-
const credentials = await this.getCredentials()
|
|
518
|
-
const url = this.buildUrl(bucket, key)
|
|
519
|
-
|
|
520
|
-
const signed = signRequest({
|
|
521
|
-
method: 'HEAD',
|
|
522
|
-
url,
|
|
523
|
-
...credentials,
|
|
524
|
-
service: 's3',
|
|
525
|
-
region: this.region,
|
|
526
|
-
})
|
|
527
|
-
|
|
528
|
-
const response = await fetch(signed.url, {
|
|
529
|
-
method: signed.method,
|
|
530
|
-
headers: signed.headers,
|
|
531
|
-
})
|
|
532
|
-
|
|
533
|
-
if (response.status === 404) {
|
|
534
|
-
return null
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
if (!response.ok) {
|
|
538
|
-
const error = await response.text()
|
|
539
|
-
throw new S3Error(`Failed to head object: ${error}`, response.status, bucket, key)
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// Extract metadata
|
|
543
|
-
const metadata: Record<string, string> = {}
|
|
544
|
-
response.headers.forEach((value, key) => {
|
|
545
|
-
if (key.toLowerCase().startsWith('x-amz-meta-')) {
|
|
546
|
-
metadata[key.slice(11)] = value
|
|
547
|
-
}
|
|
548
|
-
})
|
|
549
|
-
|
|
550
|
-
return {
|
|
551
|
-
contentLength: Number(response.headers.get('Content-Length') || 0),
|
|
552
|
-
contentType: response.headers.get('Content-Type') || 'application/octet-stream',
|
|
553
|
-
etag: (response.headers.get('ETag') || '').replace(/"/g, ''),
|
|
554
|
-
lastModified: new Date(response.headers.get('Last-Modified') || 0),
|
|
555
|
-
metadata,
|
|
556
|
-
storageClass: response.headers.get('x-amz-storage-class') || undefined,
|
|
557
|
-
serverSideEncryption: response.headers.get('x-amz-server-side-encryption') || undefined,
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
/**
|
|
562
|
-
* Check if an object exists
|
|
563
|
-
*/
|
|
564
|
-
async exists(bucket: string, key: string): Promise<boolean> {
|
|
565
|
-
const result = await this.head(bucket, key)
|
|
566
|
-
return result !== null
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Copy an object
|
|
571
|
-
*/
|
|
572
|
-
async copy(
|
|
573
|
-
sourceBucket: string,
|
|
574
|
-
sourceKey: string,
|
|
575
|
-
destBucket: string,
|
|
576
|
-
destKey: string,
|
|
577
|
-
options: CopyObjectOptions = {},
|
|
578
|
-
): Promise<{ etag: string }> {
|
|
579
|
-
const credentials = await this.getCredentials()
|
|
580
|
-
const url = this.buildUrl(destBucket, destKey)
|
|
581
|
-
|
|
582
|
-
const headers: Record<string, string> = {
|
|
583
|
-
'x-amz-copy-source': `/${sourceBucket}/${encodeURIComponent(sourceKey)}`,
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
if (options.metadataDirective) headers['x-amz-metadata-directive'] = options.metadataDirective
|
|
587
|
-
if (options.contentType) headers['Content-Type'] = options.contentType
|
|
588
|
-
if (options.storageClass) headers['x-amz-storage-class'] = options.storageClass
|
|
589
|
-
if (options.acl) headers['x-amz-acl'] = options.acl
|
|
590
|
-
|
|
591
|
-
if (options.metadata) {
|
|
592
|
-
for (const [k, v] of Object.entries(options.metadata)) {
|
|
593
|
-
headers[`x-amz-meta-${k.toLowerCase()}`] = v
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
const signed = signRequest({
|
|
598
|
-
method: 'PUT',
|
|
599
|
-
url,
|
|
600
|
-
headers,
|
|
601
|
-
...credentials,
|
|
602
|
-
service: 's3',
|
|
603
|
-
region: this.region,
|
|
604
|
-
})
|
|
605
|
-
|
|
606
|
-
const response = await fetch(signed.url, {
|
|
607
|
-
method: signed.method,
|
|
608
|
-
headers: signed.headers,
|
|
609
|
-
})
|
|
610
|
-
|
|
611
|
-
if (!response.ok) {
|
|
612
|
-
const error = await response.text()
|
|
613
|
-
throw new S3Error(`Failed to copy object: ${error}`, response.status, destBucket, destKey)
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const xml = await response.text()
|
|
617
|
-
const etagMatch = xml.match(/<ETag>"?([^"<]+)"?<\/ETag>/)
|
|
618
|
-
const etag = etagMatch ? etagMatch[1] : ''
|
|
619
|
-
|
|
620
|
-
return { etag }
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
/**
|
|
624
|
-
* Generate a presigned URL for an object
|
|
625
|
-
*/
|
|
626
|
-
async getPresignedUrl(
|
|
627
|
-
bucket: string,
|
|
628
|
-
key: string,
|
|
629
|
-
options: PresignedUrlOptions = {},
|
|
630
|
-
): Promise<string> {
|
|
631
|
-
const credentials = await this.getCredentials()
|
|
632
|
-
const url = this.buildUrl(bucket, key)
|
|
633
|
-
|
|
634
|
-
return createPresignedUrl({
|
|
635
|
-
url,
|
|
636
|
-
method: options.method || 'GET',
|
|
637
|
-
expiresIn: options.expiresIn || 3600,
|
|
638
|
-
...credentials,
|
|
639
|
-
service: 's3',
|
|
640
|
-
region: this.region,
|
|
641
|
-
})
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* Multipart upload for large files or streams
|
|
646
|
-
*/
|
|
647
|
-
async uploadMultipart(
|
|
648
|
-
bucket: string,
|
|
649
|
-
key: string,
|
|
650
|
-
body: ReadableStream | Blob | ArrayBuffer | Uint8Array,
|
|
651
|
-
options: MultipartUploadOptions = {},
|
|
652
|
-
): Promise<{ etag: string }> {
|
|
653
|
-
const credentials = await this.getCredentials()
|
|
654
|
-
const partSize = Math.max(options.partSize || DEFAULT_PART_SIZE, MIN_PART_SIZE)
|
|
655
|
-
|
|
656
|
-
// Convert to ReadableStream
|
|
657
|
-
let stream: ReadableStream<Uint8Array>
|
|
658
|
-
let totalSize: number | undefined
|
|
659
|
-
|
|
660
|
-
if (body instanceof ReadableStream) {
|
|
661
|
-
stream = body as ReadableStream<Uint8Array>
|
|
662
|
-
} else if (body instanceof Blob) {
|
|
663
|
-
stream = body.stream()
|
|
664
|
-
totalSize = body.size
|
|
665
|
-
} else {
|
|
666
|
-
const blob = new Blob([body])
|
|
667
|
-
stream = blob.stream()
|
|
668
|
-
totalSize = blob.size
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// Initiate multipart upload
|
|
672
|
-
const uploadId = await this.initiateMultipartUpload(bucket, key, options)
|
|
673
|
-
|
|
674
|
-
try {
|
|
675
|
-
// Upload parts
|
|
676
|
-
const parts = await this.uploadParts(
|
|
677
|
-
bucket,
|
|
678
|
-
key,
|
|
679
|
-
uploadId,
|
|
680
|
-
stream,
|
|
681
|
-
partSize,
|
|
682
|
-
credentials,
|
|
683
|
-
totalSize,
|
|
684
|
-
options.concurrency || 4,
|
|
685
|
-
options.onProgress,
|
|
686
|
-
)
|
|
687
|
-
|
|
688
|
-
// Complete multipart upload
|
|
689
|
-
return await this.completeMultipartUpload(bucket, key, uploadId, parts)
|
|
690
|
-
} catch (error) {
|
|
691
|
-
// Abort on failure
|
|
692
|
-
await this.abortMultipartUpload(bucket, key, uploadId).catch(() => {})
|
|
693
|
-
throw error
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/**
|
|
698
|
-
* Initiate a multipart upload
|
|
699
|
-
*/
|
|
700
|
-
private async initiateMultipartUpload(
|
|
701
|
-
bucket: string,
|
|
702
|
-
key: string,
|
|
703
|
-
options: PutObjectOptions,
|
|
704
|
-
): Promise<string> {
|
|
705
|
-
const credentials = await this.getCredentials()
|
|
706
|
-
const url = `${this.buildUrl(bucket, key)}?uploads`
|
|
707
|
-
|
|
708
|
-
const headers: Record<string, string> = {
|
|
709
|
-
'Content-Type': options.contentType || detectContentType(key),
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
if (options.storageClass) headers['x-amz-storage-class'] = options.storageClass
|
|
713
|
-
if (options.serverSideEncryption) headers['x-amz-server-side-encryption'] = options.serverSideEncryption
|
|
714
|
-
if (options.acl) headers['x-amz-acl'] = options.acl
|
|
715
|
-
|
|
716
|
-
if (options.metadata) {
|
|
717
|
-
for (const [k, v] of Object.entries(options.metadata)) {
|
|
718
|
-
headers[`x-amz-meta-${k.toLowerCase()}`] = v
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
const signed = signRequest({
|
|
723
|
-
method: 'POST',
|
|
724
|
-
url,
|
|
725
|
-
headers,
|
|
726
|
-
...credentials,
|
|
727
|
-
service: 's3',
|
|
728
|
-
region: this.region,
|
|
729
|
-
})
|
|
730
|
-
|
|
731
|
-
const response = await fetch(signed.url, {
|
|
732
|
-
method: signed.method,
|
|
733
|
-
headers: signed.headers,
|
|
734
|
-
})
|
|
735
|
-
|
|
736
|
-
if (!response.ok) {
|
|
737
|
-
const error = await response.text()
|
|
738
|
-
throw new S3Error(`Failed to initiate multipart upload: ${error}`, response.status, bucket, key)
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
const xml = await response.text()
|
|
742
|
-
const uploadIdMatch = xml.match(/<UploadId>([^<]+)<\/UploadId>/)
|
|
743
|
-
|
|
744
|
-
if (!uploadIdMatch) {
|
|
745
|
-
throw new S3Error('Failed to parse upload ID from response', 0, bucket, key)
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
return uploadIdMatch[1]
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
/**
|
|
752
|
-
* Upload parts of a multipart upload
|
|
753
|
-
*/
|
|
754
|
-
private async uploadParts(
|
|
755
|
-
bucket: string,
|
|
756
|
-
key: string,
|
|
757
|
-
uploadId: string,
|
|
758
|
-
stream: ReadableStream<Uint8Array>,
|
|
759
|
-
partSize: number,
|
|
760
|
-
credentials: AWSCredentials,
|
|
761
|
-
totalSize: number | undefined,
|
|
762
|
-
concurrency: number,
|
|
763
|
-
onProgress?: (progress: MultipartProgress) => void,
|
|
764
|
-
): Promise<Array<{ partNumber: number, etag: string }>> {
|
|
765
|
-
const parts: Array<{ partNumber: number, etag: string }> = []
|
|
766
|
-
const reader = stream.getReader()
|
|
767
|
-
let partNumber = 1
|
|
768
|
-
let buffer = new Uint8Array(0)
|
|
769
|
-
let loaded = 0
|
|
770
|
-
|
|
771
|
-
const totalParts = totalSize ? Math.ceil(totalSize / partSize) : undefined
|
|
772
|
-
|
|
773
|
-
const uploadQueue: Array<Promise<{ partNumber: number, etag: string }>> = []
|
|
774
|
-
|
|
775
|
-
const uploadPart = async (data: Uint8Array, num: number): Promise<{ partNumber: number, etag: string }> => {
|
|
776
|
-
const url = `${this.buildUrl(bucket, key)}?partNumber=${num}&uploadId=${encodeURIComponent(uploadId)}`
|
|
777
|
-
|
|
778
|
-
// Use UNSIGNED-PAYLOAD for streaming
|
|
779
|
-
const headers: Record<string, string> = {
|
|
780
|
-
'Content-Length': String(data.byteLength),
|
|
781
|
-
'x-amz-content-sha256': 'UNSIGNED-PAYLOAD',
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
const signed = signRequest({
|
|
785
|
-
method: 'PUT',
|
|
786
|
-
url,
|
|
787
|
-
headers,
|
|
788
|
-
...credentials,
|
|
789
|
-
service: 's3',
|
|
790
|
-
region: this.region,
|
|
791
|
-
})
|
|
792
|
-
|
|
793
|
-
const response = await fetch(signed.url, {
|
|
794
|
-
method: signed.method,
|
|
795
|
-
headers: signed.headers,
|
|
796
|
-
body: data,
|
|
797
|
-
})
|
|
798
|
-
|
|
799
|
-
if (!response.ok) {
|
|
800
|
-
const error = await response.text()
|
|
801
|
-
throw new S3Error(`Failed to upload part ${num}: ${error}`, response.status, bucket, key)
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
const etag = (response.headers.get('ETag') || '').replace(/"/g, '')
|
|
805
|
-
return { partNumber: num, etag }
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
while (true) {
|
|
809
|
-
const { done, value } = await reader.read()
|
|
810
|
-
|
|
811
|
-
if (value) {
|
|
812
|
-
// Append to buffer
|
|
813
|
-
const newBuffer = new Uint8Array(buffer.length + value.length)
|
|
814
|
-
newBuffer.set(buffer)
|
|
815
|
-
newBuffer.set(value, buffer.length)
|
|
816
|
-
buffer = newBuffer
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
// Upload parts when buffer reaches partSize or stream is done
|
|
820
|
-
while (buffer.length >= partSize || (done && buffer.length > 0)) {
|
|
821
|
-
const partData = buffer.slice(0, partSize)
|
|
822
|
-
buffer = buffer.slice(partSize)
|
|
823
|
-
|
|
824
|
-
const currentPartNumber = partNumber++
|
|
825
|
-
|
|
826
|
-
// Limit concurrency
|
|
827
|
-
if (uploadQueue.length >= concurrency) {
|
|
828
|
-
const completed = await Promise.race(uploadQueue)
|
|
829
|
-
parts.push(completed)
|
|
830
|
-
uploadQueue.splice(uploadQueue.indexOf(Promise.resolve(completed)), 1)
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
const uploadPromise = uploadPart(partData, currentPartNumber)
|
|
834
|
-
uploadQueue.push(uploadPromise)
|
|
835
|
-
|
|
836
|
-
loaded += partData.byteLength
|
|
837
|
-
if (onProgress) {
|
|
838
|
-
onProgress({
|
|
839
|
-
loaded,
|
|
840
|
-
total: totalSize || loaded,
|
|
841
|
-
part: currentPartNumber,
|
|
842
|
-
totalParts: totalParts || currentPartNumber,
|
|
843
|
-
})
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
if (done) break
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// Wait for remaining uploads
|
|
851
|
-
const remaining = await Promise.all(uploadQueue)
|
|
852
|
-
parts.push(...remaining)
|
|
853
|
-
|
|
854
|
-
// Sort by part number
|
|
855
|
-
parts.sort((a, b) => a.partNumber - b.partNumber)
|
|
856
|
-
|
|
857
|
-
return parts
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
/**
|
|
861
|
-
* Complete a multipart upload
|
|
862
|
-
*/
|
|
863
|
-
private async completeMultipartUpload(
|
|
864
|
-
bucket: string,
|
|
865
|
-
key: string,
|
|
866
|
-
uploadId: string,
|
|
867
|
-
parts: Array<{ partNumber: number, etag: string }>,
|
|
868
|
-
): Promise<{ etag: string }> {
|
|
869
|
-
const credentials = await this.getCredentials()
|
|
870
|
-
const url = `${this.buildUrl(bucket, key)}?uploadId=${encodeURIComponent(uploadId)}`
|
|
871
|
-
|
|
872
|
-
// Build XML body
|
|
873
|
-
const partsXml = parts
|
|
874
|
-
.map(p => `<Part><PartNumber>${p.partNumber}</PartNumber><ETag>"${p.etag}"</ETag></Part>`)
|
|
875
|
-
.join('')
|
|
876
|
-
const body = `<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>${partsXml}</CompleteMultipartUpload>`
|
|
877
|
-
|
|
878
|
-
const signed = signRequest({
|
|
879
|
-
method: 'POST',
|
|
880
|
-
url,
|
|
881
|
-
headers: { 'Content-Type': 'application/xml' },
|
|
882
|
-
body,
|
|
883
|
-
...credentials,
|
|
884
|
-
service: 's3',
|
|
885
|
-
region: this.region,
|
|
886
|
-
})
|
|
887
|
-
|
|
888
|
-
const response = await fetch(signed.url, {
|
|
889
|
-
method: signed.method,
|
|
890
|
-
headers: signed.headers,
|
|
891
|
-
body,
|
|
892
|
-
})
|
|
893
|
-
|
|
894
|
-
if (!response.ok) {
|
|
895
|
-
const error = await response.text()
|
|
896
|
-
throw new S3Error(`Failed to complete multipart upload: ${error}`, response.status, bucket, key)
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
const xml = await response.text()
|
|
900
|
-
const etagMatch = xml.match(/<ETag>"?([^"<]+)"?<\/ETag>/)
|
|
901
|
-
const etag = etagMatch ? etagMatch[1] : ''
|
|
902
|
-
|
|
903
|
-
return { etag }
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
/**
|
|
907
|
-
* Abort a multipart upload
|
|
908
|
-
*/
|
|
909
|
-
async abortMultipartUpload(bucket: string, key: string, uploadId: string): Promise<void> {
|
|
910
|
-
const credentials = await this.getCredentials()
|
|
911
|
-
const url = `${this.buildUrl(bucket, key)}?uploadId=${encodeURIComponent(uploadId)}`
|
|
912
|
-
|
|
913
|
-
const signed = signRequest({
|
|
914
|
-
method: 'DELETE',
|
|
915
|
-
url,
|
|
916
|
-
...credentials,
|
|
917
|
-
service: 's3',
|
|
918
|
-
region: this.region,
|
|
919
|
-
})
|
|
920
|
-
|
|
921
|
-
await fetch(signed.url, {
|
|
922
|
-
method: signed.method,
|
|
923
|
-
headers: signed.headers,
|
|
924
|
-
})
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
/**
|
|
929
|
-
* S3 Error class
|
|
930
|
-
*/
|
|
931
|
-
export class S3Error extends Error {
|
|
932
|
-
constructor(
|
|
933
|
-
message: string,
|
|
934
|
-
public statusCode: number,
|
|
935
|
-
public bucket: string,
|
|
936
|
-
public key?: string,
|
|
937
|
-
) {
|
|
938
|
-
super(message)
|
|
939
|
-
this.name = 'S3Error'
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
/**
|
|
944
|
-
* Parse ListObjectsV2 XML response
|
|
945
|
-
*/
|
|
946
|
-
function parseListObjectsResponse(xml: string): ListObjectsResult {
|
|
947
|
-
const contents: S3Object[] = []
|
|
948
|
-
const commonPrefixes: string[] = []
|
|
949
|
-
|
|
950
|
-
// Parse Contents
|
|
951
|
-
const contentsRegex = /<Contents>([\s\S]*?)<\/Contents>/g
|
|
952
|
-
let match
|
|
953
|
-
while ((match = contentsRegex.exec(xml)) !== null) {
|
|
954
|
-
const content = match[1]
|
|
955
|
-
contents.push({
|
|
956
|
-
key: extractXmlValue(content, 'Key') || '',
|
|
957
|
-
lastModified: new Date(extractXmlValue(content, 'LastModified') || 0),
|
|
958
|
-
etag: (extractXmlValue(content, 'ETag') || '').replace(/"/g, ''),
|
|
959
|
-
size: Number(extractXmlValue(content, 'Size') || 0),
|
|
960
|
-
storageClass: extractXmlValue(content, 'StorageClass') || 'STANDARD',
|
|
961
|
-
})
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// Parse CommonPrefixes
|
|
965
|
-
const prefixRegex = /<CommonPrefixes><Prefix>([^<]+)<\/Prefix><\/CommonPrefixes>/g
|
|
966
|
-
while ((match = prefixRegex.exec(xml)) !== null) {
|
|
967
|
-
commonPrefixes.push(match[1])
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
return {
|
|
971
|
-
contents,
|
|
972
|
-
commonPrefixes,
|
|
973
|
-
isTruncated: extractXmlValue(xml, 'IsTruncated') === 'true',
|
|
974
|
-
continuationToken: extractXmlValue(xml, 'ContinuationToken') || undefined,
|
|
975
|
-
nextContinuationToken: extractXmlValue(xml, 'NextContinuationToken') || undefined,
|
|
976
|
-
keyCount: Number(extractXmlValue(xml, 'KeyCount') || 0),
|
|
977
|
-
maxKeys: Number(extractXmlValue(xml, 'MaxKeys') || 1000),
|
|
978
|
-
prefix: extractXmlValue(xml, 'Prefix') || undefined,
|
|
979
|
-
delimiter: extractXmlValue(xml, 'Delimiter') || undefined,
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
/**
|
|
984
|
-
* Extract value from XML element
|
|
985
|
-
*/
|
|
986
|
-
function extractXmlValue(xml: string, tagName: string): string | null {
|
|
987
|
-
const regex = new RegExp(`<${tagName}>([^<]*)</${tagName}>`)
|
|
988
|
-
const match = xml.match(regex)
|
|
989
|
-
return match ? match[1] : null
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
/**
|
|
993
|
-
* Escape XML special characters
|
|
994
|
-
*/
|
|
995
|
-
function escapeXml(str: string): string {
|
|
996
|
-
return str
|
|
997
|
-
.replace(/&/g, '&')
|
|
998
|
-
.replace(/</g, '<')
|
|
999
|
-
.replace(/>/g, '>')
|
|
1000
|
-
.replace(/"/g, '"')
|
|
1001
|
-
.replace(/'/g, ''')
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
/**
|
|
1005
|
-
* Detect content type from file extension
|
|
1006
|
-
*/
|
|
1007
|
-
function detectContentType(key: string): string {
|
|
1008
|
-
const ext = key.split('.').pop()?.toLowerCase()
|
|
1009
|
-
|
|
1010
|
-
const contentTypes: Record<string, string> = {
|
|
1011
|
-
// Text
|
|
1012
|
-
'html': 'text/html',
|
|
1013
|
-
'htm': 'text/html',
|
|
1014
|
-
'css': 'text/css',
|
|
1015
|
-
'js': 'application/javascript',
|
|
1016
|
-
'mjs': 'application/javascript',
|
|
1017
|
-
'json': 'application/json',
|
|
1018
|
-
'xml': 'application/xml',
|
|
1019
|
-
'txt': 'text/plain',
|
|
1020
|
-
'md': 'text/markdown',
|
|
1021
|
-
'csv': 'text/csv',
|
|
1022
|
-
|
|
1023
|
-
// Images
|
|
1024
|
-
'jpg': 'image/jpeg',
|
|
1025
|
-
'jpeg': 'image/jpeg',
|
|
1026
|
-
'png': 'image/png',
|
|
1027
|
-
'gif': 'image/gif',
|
|
1028
|
-
'webp': 'image/webp',
|
|
1029
|
-
'svg': 'image/svg+xml',
|
|
1030
|
-
'ico': 'image/x-icon',
|
|
1031
|
-
'avif': 'image/avif',
|
|
1032
|
-
|
|
1033
|
-
// Video
|
|
1034
|
-
'mp4': 'video/mp4',
|
|
1035
|
-
'webm': 'video/webm',
|
|
1036
|
-
'mov': 'video/quicktime',
|
|
1037
|
-
'avi': 'video/x-msvideo',
|
|
1038
|
-
|
|
1039
|
-
// Audio
|
|
1040
|
-
'mp3': 'audio/mpeg',
|
|
1041
|
-
'wav': 'audio/wav',
|
|
1042
|
-
'ogg': 'audio/ogg',
|
|
1043
|
-
|
|
1044
|
-
// Documents
|
|
1045
|
-
'pdf': 'application/pdf',
|
|
1046
|
-
'doc': 'application/msword',
|
|
1047
|
-
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
1048
|
-
'xls': 'application/vnd.ms-excel',
|
|
1049
|
-
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
1050
|
-
'ppt': 'application/vnd.ms-powerpoint',
|
|
1051
|
-
'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
1052
|
-
|
|
1053
|
-
// Archives
|
|
1054
|
-
'zip': 'application/zip',
|
|
1055
|
-
'gz': 'application/gzip',
|
|
1056
|
-
'tar': 'application/x-tar',
|
|
1057
|
-
'rar': 'application/vnd.rar',
|
|
1058
|
-
'7z': 'application/x-7z-compressed',
|
|
1059
|
-
|
|
1060
|
-
// Fonts
|
|
1061
|
-
'woff': 'font/woff',
|
|
1062
|
-
'woff2': 'font/woff2',
|
|
1063
|
-
'ttf': 'font/ttf',
|
|
1064
|
-
'otf': 'font/otf',
|
|
1065
|
-
'eot': 'application/vnd.ms-fontobject',
|
|
1066
|
-
|
|
1067
|
-
// Data
|
|
1068
|
-
'wasm': 'application/wasm',
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
return contentTypes[ext || ''] || 'application/octet-stream'
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
/**
|
|
1075
|
-
* Convert various body types to Blob
|
|
1076
|
-
*/
|
|
1077
|
-
function bodyToBlob(body: string | ArrayBuffer | Uint8Array | Blob): Blob {
|
|
1078
|
-
if (body instanceof Blob) return body
|
|
1079
|
-
if (typeof body === 'string') return new Blob([body], { type: 'text/plain' })
|
|
1080
|
-
return new Blob([body])
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
/**
|
|
1084
|
-
* Convenience function to create an S3 client
|
|
1085
|
-
*/
|
|
1086
|
-
export function createS3Client(options?: S3ClientOptions): S3Client {
|
|
1087
|
-
return new S3Client(options)
|
|
1088
|
-
}
|