s3mini 0.9.0 → 0.9.2
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 +606 -162
- package/dist/s3mini.d.ts +65 -12
- package/dist/s3mini.js +329 -26
- package/dist/s3mini.js.map +1 -1
- package/dist/s3mini.min.js +1 -1
- package/dist/s3mini.min.js.map +1 -1
- package/package.json +18 -20
- package/src/S3.ts +361 -22
- package/src/consts.ts +1 -0
- package/src/types.ts +11 -8
- package/src/utils.ts +165 -3
package/src/S3.ts
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
extractErrCode,
|
|
16
16
|
S3NetworkError,
|
|
17
17
|
S3ServiceError,
|
|
18
|
+
generateParts,
|
|
19
|
+
toUint8Array,
|
|
18
20
|
} from './utils.js';
|
|
19
21
|
import type * as IT from './types.js';
|
|
20
22
|
|
|
@@ -55,6 +57,7 @@ class S3mini {
|
|
|
55
57
|
* @param {number} [config.requestAbortTimeout=undefined] - The timeout in milliseconds after which a request should be aborted (careful on streamed requests).
|
|
56
58
|
* @param {Object} [config.logger=null] - A logger object with methods like info, warn, error.
|
|
57
59
|
* @param {typeof fetch} [config.fetch=globalThis.fetch] - Custom fetch implementation to use for HTTP requests.
|
|
60
|
+
* @param {number} [config.minPartSize=8388608] - The minimum part size for multipart uploads in bytes (default is 8MB).
|
|
58
61
|
* @throws {TypeError} Will throw an error if required parameters are missing or of incorrect type.
|
|
59
62
|
*/
|
|
60
63
|
readonly #accessKeyId: string;
|
|
@@ -66,6 +69,7 @@ class S3mini {
|
|
|
66
69
|
readonly requestAbortTimeout?: number;
|
|
67
70
|
readonly logger?: IT.Logger;
|
|
68
71
|
readonly _fetch: typeof fetch;
|
|
72
|
+
readonly minPartSize: number;
|
|
69
73
|
private signingKeyDate?: string;
|
|
70
74
|
private signingKey?: ArrayBuffer;
|
|
71
75
|
|
|
@@ -78,6 +82,7 @@ class S3mini {
|
|
|
78
82
|
requestAbortTimeout = undefined,
|
|
79
83
|
logger = undefined,
|
|
80
84
|
fetch = globalThis.fetch,
|
|
85
|
+
minPartSize = C.MIN_PART_SIZE,
|
|
81
86
|
}: IT.S3Config) {
|
|
82
87
|
this._validateConstructorParams(accessKeyId, secretAccessKey, endpoint);
|
|
83
88
|
this.#accessKeyId = accessKeyId;
|
|
@@ -89,6 +94,7 @@ class S3mini {
|
|
|
89
94
|
this.requestAbortTimeout = requestAbortTimeout;
|
|
90
95
|
this.logger = logger;
|
|
91
96
|
this._fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => fetch(input, init);
|
|
97
|
+
this.minPartSize = minPartSize;
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
private _sanitize(obj: unknown): unknown {
|
|
@@ -241,18 +247,24 @@ class S3mini {
|
|
|
241
247
|
return { filteredOpts, conditionalHeaders };
|
|
242
248
|
}
|
|
243
249
|
|
|
244
|
-
private _validateData(data: unknown): BodyInit {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
250
|
+
// private _validateData(data: unknown): BodyInit {
|
|
251
|
+
// if (data instanceof ArrayBuffer) {
|
|
252
|
+
// return data;
|
|
253
|
+
// }
|
|
254
|
+
// if (data instanceof Uint8Array) {
|
|
255
|
+
// return data as unknown as BodyInit;
|
|
256
|
+
// }
|
|
257
|
+
// if ((globalThis.Buffer && data instanceof globalThis.Buffer) || typeof data === 'string') {
|
|
258
|
+
// return data as BodyInit;
|
|
259
|
+
// }
|
|
260
|
+
// this._log('error', C.ERROR_DATA_BUFFER_REQUIRED);
|
|
261
|
+
// throw new TypeError(C.ERROR_DATA_BUFFER_REQUIRED);
|
|
262
|
+
// }
|
|
251
263
|
|
|
252
264
|
private _validateUploadPartParams(
|
|
253
265
|
key: string,
|
|
254
266
|
uploadId: string,
|
|
255
|
-
data: IT.
|
|
267
|
+
data: IT.DataInput,
|
|
256
268
|
partNumber: number,
|
|
257
269
|
opts: object,
|
|
258
270
|
): BodyInit {
|
|
@@ -266,7 +278,7 @@ class S3mini {
|
|
|
266
278
|
throw new TypeError(`${C.ERROR_PREFIX}partNumber must be a positive integer`);
|
|
267
279
|
}
|
|
268
280
|
this._checkOpts(opts);
|
|
269
|
-
return
|
|
281
|
+
return data as BodyInit;
|
|
270
282
|
}
|
|
271
283
|
|
|
272
284
|
private async _sign(
|
|
@@ -564,6 +576,7 @@ class S3mini {
|
|
|
564
576
|
});
|
|
565
577
|
|
|
566
578
|
if (res.status === 404) {
|
|
579
|
+
void res.body?.cancel();
|
|
567
580
|
return null;
|
|
568
581
|
}
|
|
569
582
|
|
|
@@ -634,10 +647,16 @@ class S3mini {
|
|
|
634
647
|
|
|
635
648
|
// Extract regular objects from Contents
|
|
636
649
|
if (contents) {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
objects.push(
|
|
650
|
+
const raw = Array.isArray(contents) ? contents : [contents];
|
|
651
|
+
for (const item of raw) {
|
|
652
|
+
const o = item as Record<string, string>;
|
|
653
|
+
objects.push({
|
|
654
|
+
Key: o.Key ?? o.key ?? '',
|
|
655
|
+
Size: Number(o.Size ?? o.size ?? 0),
|
|
656
|
+
LastModified: new Date(o.LastModified ?? o.lastModified ?? 0),
|
|
657
|
+
ETag: o.ETag ?? o.etag ?? '',
|
|
658
|
+
StorageClass: o.StorageClass ?? o.storageClass ?? '',
|
|
659
|
+
});
|
|
641
660
|
}
|
|
642
661
|
}
|
|
643
662
|
|
|
@@ -742,6 +761,7 @@ class S3mini {
|
|
|
742
761
|
if (s === 200) {
|
|
743
762
|
return res.text();
|
|
744
763
|
}
|
|
764
|
+
void res.body?.cancel();
|
|
745
765
|
return null;
|
|
746
766
|
}
|
|
747
767
|
|
|
@@ -766,6 +786,7 @@ class S3mini {
|
|
|
766
786
|
if (res.status === 200) {
|
|
767
787
|
return res;
|
|
768
788
|
}
|
|
789
|
+
void res.body?.cancel();
|
|
769
790
|
return null;
|
|
770
791
|
}
|
|
771
792
|
|
|
@@ -790,6 +811,7 @@ class S3mini {
|
|
|
790
811
|
if (res.status === 200) {
|
|
791
812
|
return res.arrayBuffer();
|
|
792
813
|
}
|
|
814
|
+
void res.body?.cancel();
|
|
793
815
|
return null;
|
|
794
816
|
}
|
|
795
817
|
|
|
@@ -814,6 +836,7 @@ class S3mini {
|
|
|
814
836
|
if (res.status === 200) {
|
|
815
837
|
return res.json() as Promise<T>;
|
|
816
838
|
}
|
|
839
|
+
void res.body?.cancel();
|
|
817
840
|
return null;
|
|
818
841
|
}
|
|
819
842
|
|
|
@@ -838,6 +861,7 @@ class S3mini {
|
|
|
838
861
|
});
|
|
839
862
|
const s = res.status;
|
|
840
863
|
if (s === 404 || s === 412 || s === 304) {
|
|
864
|
+
void res.body?.cancel();
|
|
841
865
|
return { etag: null, data: null };
|
|
842
866
|
}
|
|
843
867
|
|
|
@@ -900,7 +924,9 @@ class S3mini {
|
|
|
900
924
|
return len ? +len : 0;
|
|
901
925
|
} catch (err) {
|
|
902
926
|
this._log('error', `Error getting content length for object ${key}: ${String(err)}`);
|
|
903
|
-
throw new Error(`${C.ERROR_PREFIX}Error getting content length for object ${key}: ${String(err)}
|
|
927
|
+
throw new Error(`${C.ERROR_PREFIX}Error getting content length for object ${key}: ${String(err)}`, {
|
|
928
|
+
cause: err,
|
|
929
|
+
});
|
|
904
930
|
}
|
|
905
931
|
}
|
|
906
932
|
|
|
@@ -969,7 +995,7 @@ class S3mini {
|
|
|
969
995
|
/**
|
|
970
996
|
* Uploads an object to the S3-compatible service.
|
|
971
997
|
* @param {string} key - The key/path where the object will be stored.
|
|
972
|
-
* @param {string |
|
|
998
|
+
* @param {string | IT.MaybeBuffer | ReadableStream | File | Blob} data - The data to upload (string or Buffer).
|
|
973
999
|
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
974
1000
|
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
975
1001
|
* @param {IT.AWSHeaders} [additionalHeaders] - Additional x-amz-* headers specific to this request, if any.
|
|
@@ -985,15 +1011,17 @@ class S3mini {
|
|
|
985
1011
|
*/
|
|
986
1012
|
public async putObject(
|
|
987
1013
|
key: string,
|
|
988
|
-
data: string | IT.
|
|
1014
|
+
data: string | IT.DataInput | ReadableStream | File | Blob,
|
|
989
1015
|
fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
|
|
990
1016
|
ssecHeaders?: IT.SSECHeaders,
|
|
991
1017
|
additionalHeaders?: IT.AWSHeaders,
|
|
1018
|
+
contentLength?: number,
|
|
992
1019
|
): Promise<Response> {
|
|
1020
|
+
const size = contentLength ?? getByteSize(data);
|
|
993
1021
|
return this._signedRequest('PUT', key, {
|
|
994
|
-
body:
|
|
1022
|
+
body: data as BodyInit,
|
|
995
1023
|
headers: {
|
|
996
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
1024
|
+
...(size && { [C.HEADER_CONTENT_LENGTH]: size }),
|
|
997
1025
|
[C.HEADER_CONTENT_TYPE]: fileType,
|
|
998
1026
|
...additionalHeaders,
|
|
999
1027
|
...ssecHeaders,
|
|
@@ -1002,6 +1030,235 @@ class S3mini {
|
|
|
1002
1030
|
});
|
|
1003
1031
|
}
|
|
1004
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* Put object that automatically chooses single PUT vs multipart.
|
|
1035
|
+
* Same signature/shape as putObject so callers don't need to change.
|
|
1036
|
+
* @param {string} key - The key/path where the object will be stored.
|
|
1037
|
+
* @param {string | IT.MaybeBuffer | ReadableStream | File | Blob} data - The data to upload (string or Buffer).
|
|
1038
|
+
* @param {string} [fileType='application/octet-stream'] - The MIME type of the file.
|
|
1039
|
+
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
1040
|
+
* @param {IT.AWSHeaders} [additionalHeaders] - Additional x-amz-* headers specific to this request, if any.
|
|
1041
|
+
* @param {number} [contentLength] - Optional known content length of data.
|
|
1042
|
+
* @returns {Promise<Response | { ok: boolean; status: number; headers: Map<string, string> }>} A promise that resolves to the Response object from the upload request.
|
|
1043
|
+
* @throws {TypeError} If data is not a string or Buffer.
|
|
1044
|
+
* @example
|
|
1045
|
+
* // Upload text file
|
|
1046
|
+
* await s3.putAnyObject('hello.txt', 'Hello, World!', 'text/plain');
|
|
1047
|
+
*
|
|
1048
|
+
* // Upload binary data
|
|
1049
|
+
* const buffer = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
1050
|
+
* await s3.putAnyObject('image.png', buffer, 'image/png');
|
|
1051
|
+
*/
|
|
1052
|
+
public async putAnyObject(
|
|
1053
|
+
key: string,
|
|
1054
|
+
data: IT.DataInput,
|
|
1055
|
+
fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
|
|
1056
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
1057
|
+
additionalHeaders?: IT.AWSHeaders,
|
|
1058
|
+
contentLength?: number,
|
|
1059
|
+
): Promise<Response | { ok: boolean; status: number; headers: Map<string, string> }> {
|
|
1060
|
+
const size = contentLength ?? getByteSize(data);
|
|
1061
|
+
|
|
1062
|
+
// Single PUT for small files
|
|
1063
|
+
if (!Number.isNaN(size) && size <= this.minPartSize) {
|
|
1064
|
+
return this.putObject(key, data, fileType, ssecHeaders, additionalHeaders, contentLength);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
this._checkKey(key);
|
|
1068
|
+
return this._multipartUpload(key, data, fileType, ssecHeaders, additionalHeaders);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private async _multipartUpload(
|
|
1072
|
+
key: string,
|
|
1073
|
+
data: IT.DataInput,
|
|
1074
|
+
fileType: string,
|
|
1075
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
1076
|
+
additionalHeaders?: IT.AWSHeaders,
|
|
1077
|
+
): Promise<Response | { ok: boolean; status: number; headers: Map<string, string> }> {
|
|
1078
|
+
const uploadId = await this.getMultipartUploadId(key, fileType, ssecHeaders, additionalHeaders);
|
|
1079
|
+
|
|
1080
|
+
try {
|
|
1081
|
+
const parts = await this._uploadPartsOptimized(key, uploadId, data, ssecHeaders, additionalHeaders);
|
|
1082
|
+
parts.sort((a, b) => a.partNumber - b.partNumber);
|
|
1083
|
+
const result = await this.completeMultipartUpload(key, uploadId, parts);
|
|
1084
|
+
return this._createSuccessResponse(result.etag || '');
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
await this._safeAbortUpload(key, uploadId);
|
|
1087
|
+
throw err;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
private async _uploadKnownSizePartsParallel(
|
|
1092
|
+
key: string,
|
|
1093
|
+
uploadId: string,
|
|
1094
|
+
data: Uint8Array | Blob,
|
|
1095
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
1096
|
+
additionalHeaders?: IT.AWSHeaders,
|
|
1097
|
+
concurrency: number = 4,
|
|
1098
|
+
maxRetries: number = 3,
|
|
1099
|
+
): Promise<IT.UploadPart[]> {
|
|
1100
|
+
const partSize = this.minPartSize;
|
|
1101
|
+
const totalSize = data instanceof Blob ? data.size : data.byteLength;
|
|
1102
|
+
const totalParts = Math.ceil(totalSize / partSize);
|
|
1103
|
+
const results: IT.UploadPart[] = new Array(totalParts) as IT.UploadPart[];
|
|
1104
|
+
let nextIndex = 0;
|
|
1105
|
+
|
|
1106
|
+
const worker = async (): Promise<void> => {
|
|
1107
|
+
while (true) {
|
|
1108
|
+
const index = nextIndex++;
|
|
1109
|
+
if (index >= totalParts) {
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const start = index * partSize;
|
|
1114
|
+
const end = Math.min(start + partSize, totalSize);
|
|
1115
|
+
const part =
|
|
1116
|
+
data instanceof Blob
|
|
1117
|
+
? await data.slice(start, end).arrayBuffer() // Must await - R2 needs actual bytes
|
|
1118
|
+
: data.subarray(start, end);
|
|
1119
|
+
|
|
1120
|
+
results[index] = await this._uploadPartWithRetry(
|
|
1121
|
+
key,
|
|
1122
|
+
uploadId,
|
|
1123
|
+
part,
|
|
1124
|
+
index + 1,
|
|
1125
|
+
ssecHeaders,
|
|
1126
|
+
additionalHeaders,
|
|
1127
|
+
maxRetries,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, totalParts) }, () => worker()));
|
|
1133
|
+
return results;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
private async _uploadPartsOptimized(
|
|
1137
|
+
key: string,
|
|
1138
|
+
uploadId: string,
|
|
1139
|
+
data: IT.DataInput,
|
|
1140
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
1141
|
+
additionalHeaders?: IT.AWSHeaders,
|
|
1142
|
+
concurrency: number = 4,
|
|
1143
|
+
maxRetries: number = 3,
|
|
1144
|
+
): Promise<IT.UploadPart[]> {
|
|
1145
|
+
const bytes = toUint8Array(data);
|
|
1146
|
+
if (bytes) {
|
|
1147
|
+
return this._uploadKnownSizePartsParallel(
|
|
1148
|
+
key,
|
|
1149
|
+
uploadId,
|
|
1150
|
+
bytes,
|
|
1151
|
+
ssecHeaders,
|
|
1152
|
+
additionalHeaders,
|
|
1153
|
+
concurrency,
|
|
1154
|
+
maxRetries,
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
if (data instanceof Blob) {
|
|
1158
|
+
return this._uploadKnownSizePartsParallel(
|
|
1159
|
+
key,
|
|
1160
|
+
uploadId,
|
|
1161
|
+
data,
|
|
1162
|
+
ssecHeaders,
|
|
1163
|
+
additionalHeaders,
|
|
1164
|
+
concurrency,
|
|
1165
|
+
maxRetries,
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
return this._uploadStreamingParts(
|
|
1169
|
+
key,
|
|
1170
|
+
uploadId,
|
|
1171
|
+
data as ReadableStream,
|
|
1172
|
+
ssecHeaders,
|
|
1173
|
+
additionalHeaders,
|
|
1174
|
+
concurrency,
|
|
1175
|
+
maxRetries,
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
private async _uploadStreamingParts(
|
|
1180
|
+
key: string,
|
|
1181
|
+
uploadId: string,
|
|
1182
|
+
stream: ReadableStream,
|
|
1183
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
1184
|
+
additionalHeaders?: IT.AWSHeaders,
|
|
1185
|
+
concurrency: number = 4,
|
|
1186
|
+
maxRetries: number = 3,
|
|
1187
|
+
): Promise<IT.UploadPart[]> {
|
|
1188
|
+
const parts: IT.UploadPart[] = [];
|
|
1189
|
+
const active = new Set<Promise<void>>();
|
|
1190
|
+
let partNumber = 0;
|
|
1191
|
+
|
|
1192
|
+
for await (const partData of generateParts(stream, this.minPartSize)) {
|
|
1193
|
+
const currentPartNumber = ++partNumber;
|
|
1194
|
+
|
|
1195
|
+
while (active.size >= concurrency) {
|
|
1196
|
+
await Promise.race(active);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const p = this._uploadPartWithRetry(
|
|
1200
|
+
key,
|
|
1201
|
+
uploadId,
|
|
1202
|
+
partData,
|
|
1203
|
+
currentPartNumber,
|
|
1204
|
+
ssecHeaders,
|
|
1205
|
+
additionalHeaders,
|
|
1206
|
+
maxRetries,
|
|
1207
|
+
).then(part => {
|
|
1208
|
+
parts.push(part);
|
|
1209
|
+
active.delete(p);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
active.add(p);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
await Promise.all(active);
|
|
1216
|
+
return parts;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
private async _uploadPartWithRetry(
|
|
1220
|
+
key: string,
|
|
1221
|
+
uploadId: string,
|
|
1222
|
+
data: IT.PartData,
|
|
1223
|
+
partNumber: number,
|
|
1224
|
+
ssecHeaders?: IT.SSECHeaders,
|
|
1225
|
+
additionalHeaders?: IT.AWSHeaders,
|
|
1226
|
+
maxRetries: number = 3,
|
|
1227
|
+
): Promise<IT.UploadPart> {
|
|
1228
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1229
|
+
try {
|
|
1230
|
+
return await this.uploadPart(key, uploadId, data, partNumber, {}, ssecHeaders, additionalHeaders);
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
if (attempt === maxRetries) {
|
|
1233
|
+
throw err;
|
|
1234
|
+
}
|
|
1235
|
+
await new Promise(r => setTimeout(r, Math.min(1000 * 2 ** attempt, 10000)));
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
throw new Error('Unreachable');
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
private async _safeAbortUpload(key: string, uploadId: string): Promise<void> {
|
|
1242
|
+
try {
|
|
1243
|
+
await this.abortMultipartUpload(key, uploadId);
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
this._log('warn', `Failed to abort multipart upload: ${String(err)}`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
private _createSuccessResponse(
|
|
1250
|
+
etag: string,
|
|
1251
|
+
): Response | { ok: boolean; status: number; headers: Map<string, string> } {
|
|
1252
|
+
if (typeof Response !== 'undefined') {
|
|
1253
|
+
const headers = new Headers();
|
|
1254
|
+
if (etag) {
|
|
1255
|
+
headers.set('ETag', etag);
|
|
1256
|
+
}
|
|
1257
|
+
return new Response('', { status: 200, headers });
|
|
1258
|
+
}
|
|
1259
|
+
return { ok: true, status: 200, headers: new Map([['ETag', etag]]) };
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1005
1262
|
/**
|
|
1006
1263
|
* Initiates a multipart upload and returns the upload ID.
|
|
1007
1264
|
* @param {string} key - The key/path where the object will be stored.
|
|
@@ -1018,13 +1275,14 @@ class S3mini {
|
|
|
1018
1275
|
key: string,
|
|
1019
1276
|
fileType: string = C.DEFAULT_STREAM_CONTENT_TYPE,
|
|
1020
1277
|
ssecHeaders?: IT.SSECHeaders,
|
|
1278
|
+
additionalHeaders?: IT.AWSHeaders,
|
|
1021
1279
|
): Promise<string> {
|
|
1022
1280
|
this._checkKey(key);
|
|
1023
1281
|
if (typeof fileType !== 'string') {
|
|
1024
1282
|
throw new TypeError(`${C.ERROR_PREFIX}fileType must be a string`);
|
|
1025
1283
|
}
|
|
1026
1284
|
const query = { uploads: '' };
|
|
1027
|
-
const headers = { [C.HEADER_CONTENT_TYPE]: fileType, ...ssecHeaders };
|
|
1285
|
+
const headers = { [C.HEADER_CONTENT_TYPE]: fileType, ...ssecHeaders, ...additionalHeaders };
|
|
1028
1286
|
|
|
1029
1287
|
const res = await this._signedRequest('POST', key, {
|
|
1030
1288
|
query,
|
|
@@ -1056,7 +1314,7 @@ class S3mini {
|
|
|
1056
1314
|
* Uploads a part in a multipart upload.
|
|
1057
1315
|
* @param {string} key - The key of the object being uploaded.
|
|
1058
1316
|
* @param {string} uploadId - The upload ID from getMultipartUploadId.
|
|
1059
|
-
* @param {
|
|
1317
|
+
* @param {string | IT.MaybeBuffer | ReadableStream | File | Blob} data - The data for this part.
|
|
1060
1318
|
* @param {number} partNumber - The part number (must be between 1 and 10,000).
|
|
1061
1319
|
* @param {Record<string, unknown>} [opts={}] - Additional options for the request.
|
|
1062
1320
|
* @param {IT.SSECHeaders} [ssecHeaders] - Server-Side Encryption headers, if any.
|
|
@@ -1074,20 +1332,23 @@ class S3mini {
|
|
|
1074
1332
|
public async uploadPart(
|
|
1075
1333
|
key: string,
|
|
1076
1334
|
uploadId: string,
|
|
1077
|
-
data: IT.
|
|
1335
|
+
data: IT.DataInput,
|
|
1078
1336
|
partNumber: number,
|
|
1079
1337
|
opts: Record<string, unknown> = {},
|
|
1080
1338
|
ssecHeaders?: IT.SSECHeaders,
|
|
1339
|
+
additionalHeaders?: IT.AWSHeaders,
|
|
1081
1340
|
): Promise<IT.UploadPart> {
|
|
1082
1341
|
const body = this._validateUploadPartParams(key, uploadId, data, partNumber, opts);
|
|
1083
1342
|
|
|
1084
1343
|
const query = { uploadId, partNumber, ...opts };
|
|
1344
|
+
const size = getByteSize(data);
|
|
1085
1345
|
const res = await this._signedRequest('PUT', key, {
|
|
1086
1346
|
query,
|
|
1087
1347
|
body,
|
|
1088
1348
|
headers: {
|
|
1089
|
-
[C.HEADER_CONTENT_LENGTH]:
|
|
1349
|
+
...(size && !Number.isNaN(size) && { [C.HEADER_CONTENT_LENGTH]: size }),
|
|
1090
1350
|
...ssecHeaders,
|
|
1351
|
+
...additionalHeaders,
|
|
1091
1352
|
},
|
|
1092
1353
|
});
|
|
1093
1354
|
|
|
@@ -1600,6 +1861,84 @@ class S3mini {
|
|
|
1600
1861
|
.sort((a, b) => a.localeCompare(b))
|
|
1601
1862
|
.join('&');
|
|
1602
1863
|
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Generates a pre-signed URL for direct client access to an S3 object.
|
|
1866
|
+
* The URL embeds authentication in query parameters instead of headers,
|
|
1867
|
+
* allowing unauthenticated HTTP clients to perform the specified operation.
|
|
1868
|
+
*
|
|
1869
|
+
* @param {'GET' | 'PUT'} method - HTTP method ('GET' for download, 'PUT' for upload)
|
|
1870
|
+
* @param {string} key - The object key/path
|
|
1871
|
+
* @param {number} [expiresIn=3600] - URL expiration time in seconds (1–604800)
|
|
1872
|
+
* @param {Record<string, string>} [queryParams={}] - Additional query parameters to sign
|
|
1873
|
+
* @returns {Promise<string>} Pre-signed URL string
|
|
1874
|
+
* @throws {TypeError} If key is empty or expiresIn is out of range
|
|
1875
|
+
* @example
|
|
1876
|
+
* // Download URL valid for 1 hour
|
|
1877
|
+
* const url = await s3.getPresignedUrl('GET', 'photos/vacation.jpg');
|
|
1878
|
+
*
|
|
1879
|
+
* // Upload URL valid for 5 minutes
|
|
1880
|
+
* const url = await s3.getPresignedUrl('PUT', 'uploads/file.bin', 300);
|
|
1881
|
+
*
|
|
1882
|
+
* // Client-side usage (no credentials needed)
|
|
1883
|
+
* await fetch(url, { method: 'PUT', body: data });
|
|
1884
|
+
*/
|
|
1885
|
+
public async getPresignedUrl(
|
|
1886
|
+
method: 'GET' | 'PUT',
|
|
1887
|
+
key: string,
|
|
1888
|
+
expiresIn: number = 3600,
|
|
1889
|
+
queryParams: Record<string, string> = {},
|
|
1890
|
+
): Promise<string> {
|
|
1891
|
+
this._checkKey(key);
|
|
1892
|
+
if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
|
|
1893
|
+
throw new TypeError(`${C.ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
|
|
1894
|
+
}
|
|
1895
|
+
return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
private async _presign(
|
|
1899
|
+
method: string,
|
|
1900
|
+
keyPath: string,
|
|
1901
|
+
expiresIn: number,
|
|
1902
|
+
queryParams: Record<string, string>,
|
|
1903
|
+
): Promise<string> {
|
|
1904
|
+
const url = new URL(this.endpoint);
|
|
1905
|
+
if (keyPath.length > 0) {
|
|
1906
|
+
url.pathname =
|
|
1907
|
+
url.pathname === '/' ? `/${keyPath.replace(/^\/+/, '')}` : `${url.pathname}/${keyPath.replace(/^\/+/, '')}`;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const d = new Date();
|
|
1911
|
+
const year = d.getUTCFullYear();
|
|
1912
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
1913
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
1914
|
+
const shortDatetime = `${year}${month}${day}`;
|
|
1915
|
+
const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
|
|
1916
|
+
const credentialScope = `${shortDatetime}/${this.region}/${C.S3_SERVICE}/${C.AWS_REQUEST_TYPE}`;
|
|
1917
|
+
|
|
1918
|
+
const signedHeaders = 'host';
|
|
1919
|
+
|
|
1920
|
+
const allQueryParams: Record<string, string> = {
|
|
1921
|
+
...queryParams,
|
|
1922
|
+
'X-Amz-Algorithm': C.AWS_ALGORITHM,
|
|
1923
|
+
'X-Amz-Credential': `${this.#accessKeyId}/${credentialScope}`,
|
|
1924
|
+
'X-Amz-Date': fullDatetime,
|
|
1925
|
+
'X-Amz-Expires': String(expiresIn),
|
|
1926
|
+
'X-Amz-SignedHeaders': signedHeaders,
|
|
1927
|
+
};
|
|
1928
|
+
|
|
1929
|
+
const canonicalQueryString = this._buildCanonicalQueryString(allQueryParams);
|
|
1930
|
+
const canonicalRequest = `${method}\n${url.pathname}\n${canonicalQueryString}\nhost:${url.host}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
|
|
1931
|
+
const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
|
|
1932
|
+
|
|
1933
|
+
if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
|
|
1934
|
+
this.signingKeyDate = shortDatetime;
|
|
1935
|
+
this.signingKey = await this._getSignatureKey(shortDatetime);
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
const signature = hexFromBuffer(await hmac(this.signingKey, stringToSign));
|
|
1939
|
+
return `${url.origin}${url.pathname}?${canonicalQueryString}&X-Amz-Signature=${signature}`;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1603
1942
|
private async _getSignatureKey(dateStamp: string): Promise<ArrayBuffer> {
|
|
1604
1943
|
const kDate = await hmac(`AWS4${this.#secretAccessKey}`, dateStamp);
|
|
1605
1944
|
const kRegion = await hmac(kDate, this.region);
|
package/src/consts.ts
CHANGED
|
@@ -11,6 +11,7 @@ export const JSON_CONTENT_TYPE = 'application/json';
|
|
|
11
11
|
export const SENSITIVE_KEYS_REDACTED = new Set(['accesskeyid', 'secretaccesskey', 'sessiontoken', 'password', 'token']);
|
|
12
12
|
export const IFHEADERS = new Set(['if-match', 'if-none-match', 'if-modified-since', 'if-unmodified-since']);
|
|
13
13
|
export const DEFAULT_REQUEST_SIZE_IN_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
export const MIN_PART_SIZE = 8 * 1024 * 1024;
|
|
14
15
|
|
|
15
16
|
// Headers
|
|
16
17
|
export const HEADER_AMZ_CONTENT_SHA256 = 'x-amz-content-sha256';
|
package/src/types.ts
CHANGED
|
@@ -7,8 +7,11 @@ export interface S3Config {
|
|
|
7
7
|
requestAbortTimeout?: number;
|
|
8
8
|
logger?: Logger;
|
|
9
9
|
fetch?: typeof fetch;
|
|
10
|
+
minPartSize?: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
export type PartData = Uint8Array | Blob | ArrayBuffer;
|
|
14
|
+
|
|
12
15
|
export interface SSECHeaders {
|
|
13
16
|
'x-amz-server-side-encryption-customer-algorithm': string;
|
|
14
17
|
'x-amz-server-side-encryption-customer-key': string;
|
|
@@ -151,12 +154,12 @@ export interface CopyObjectResult {
|
|
|
151
154
|
lastModified?: Date;
|
|
152
155
|
}
|
|
153
156
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
*/
|
|
158
|
-
export type MaybeBuffer = typeof globalThis extends { Buffer?: infer B }
|
|
157
|
+
type BinaryData = ArrayBuffer | Uint8Array;
|
|
158
|
+
|
|
159
|
+
type MaybeBuffer = typeof globalThis extends { Buffer?: infer B }
|
|
159
160
|
? B extends new (...a: unknown[]) => unknown
|
|
160
|
-
? InstanceType<B>
|
|
161
|
-
:
|
|
162
|
-
:
|
|
161
|
+
? InstanceType<B> | BinaryData
|
|
162
|
+
: BinaryData
|
|
163
|
+
: BinaryData;
|
|
164
|
+
|
|
165
|
+
export type DataInput = string | MaybeBuffer | ReadableStream | File | Blob;
|