s3mini 0.9.2 → 0.9.4
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 +27 -2
- package/dist/s3mini.d.ts +20 -6
- package/dist/s3mini.js +280 -85
- 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 +13 -9
- package/src/S3.ts +299 -84
- package/src/types.ts +54 -0
- package/src/utils.ts +18 -1
package/dist/s3mini.js
CHANGED
|
@@ -31,6 +31,21 @@ const ERROR_UPLOAD_ID_REQUIRED = `${ERROR_PREFIX}uploadId must be a non-empty st
|
|
|
31
31
|
const ERROR_PREFIX_TYPE = `${ERROR_PREFIX}prefix must be a string`;
|
|
32
32
|
const ERROR_DELIMITER_REQUIRED = `${ERROR_PREFIX}delimiter must be a string`;
|
|
33
33
|
|
|
34
|
+
const isBun = typeof navigator !== 'undefined' && navigator.userAgent === 'Bun';
|
|
35
|
+
/** Strips the bucket name from a full endpoint URL, returning the base origin for Bun.S3Client. */
|
|
36
|
+
const extractBaseEndpoint = (endpoint, bucket) => {
|
|
37
|
+
// Path-style (/bucket/…): just use the origin
|
|
38
|
+
if (endpoint.pathname.split('/').some(Boolean)) {
|
|
39
|
+
return endpoint.origin;
|
|
40
|
+
}
|
|
41
|
+
// Virtual-hosted (bucket.host…): strip the bucket subdomain
|
|
42
|
+
const prefix = bucket + '.';
|
|
43
|
+
if (endpoint.hostname.startsWith(prefix)) {
|
|
44
|
+
const base = endpoint.hostname.slice(prefix.length);
|
|
45
|
+
return `${endpoint.protocol}//${base}${endpoint.port ? ':' + endpoint.port : ''}`;
|
|
46
|
+
}
|
|
47
|
+
return endpoint.origin;
|
|
48
|
+
};
|
|
34
49
|
const ENCODR = new TextEncoder();
|
|
35
50
|
const chunkSize = 0x8000; // 32KB chunks
|
|
36
51
|
const HEX_CHARS = new Uint8Array([48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102]);
|
|
@@ -155,7 +170,7 @@ const unescapeXml = (value) => value.replaceAll(/&(quot|apos|lt|gt|amp);/g, m =>
|
|
|
155
170
|
*/
|
|
156
171
|
const parseXml = (input) => {
|
|
157
172
|
const xmlContent = input.replace(/<\?xml[^?]*\?>\s*/, '');
|
|
158
|
-
const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]
|
|
173
|
+
const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]*?>([\s\S]*?)<\/\1>/gm;
|
|
159
174
|
const result = {}; // strong type, no `any`
|
|
160
175
|
let match;
|
|
161
176
|
while ((match = RE_TAG.exec(xmlContent)) !== null) {
|
|
@@ -398,6 +413,7 @@ class S3mini {
|
|
|
398
413
|
logger;
|
|
399
414
|
_fetch;
|
|
400
415
|
minPartSize;
|
|
416
|
+
_bun;
|
|
401
417
|
signingKeyDate;
|
|
402
418
|
signingKey;
|
|
403
419
|
constructor({ accessKeyId, secretAccessKey, endpoint, region = 'auto', requestSizeInBytes = DEFAULT_REQUEST_SIZE_IN_BYTES, requestAbortTimeout = undefined, logger = undefined, fetch = globalThis.fetch, minPartSize = MIN_PART_SIZE, }) {
|
|
@@ -412,6 +428,16 @@ class S3mini {
|
|
|
412
428
|
this.logger = logger;
|
|
413
429
|
this._fetch = (input, init) => fetch(input, init);
|
|
414
430
|
this.minPartSize = minPartSize;
|
|
431
|
+
if (isBun) {
|
|
432
|
+
const { S3Client } = globalThis.Bun;
|
|
433
|
+
this._bun = new S3Client({
|
|
434
|
+
accessKeyId,
|
|
435
|
+
secretAccessKey,
|
|
436
|
+
endpoint: extractBaseEndpoint(this.endpoint, this.bucketName),
|
|
437
|
+
region: this.region,
|
|
438
|
+
bucket: this.bucketName,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
415
441
|
}
|
|
416
442
|
_sanitize(obj) {
|
|
417
443
|
if (typeof obj !== 'object' || obj === null) {
|
|
@@ -472,6 +498,18 @@ class S3mini {
|
|
|
472
498
|
_hasCredentials() {
|
|
473
499
|
return this.#accessKeyId.trim().length > 0 && this.#secretAccessKey.trim().length > 0;
|
|
474
500
|
}
|
|
501
|
+
/** Run a read op via Bun-native S3, returning null on NoSuchKey. */
|
|
502
|
+
async _bunRead(key, op) {
|
|
503
|
+
try {
|
|
504
|
+
return await op(this._bun.file(key));
|
|
505
|
+
}
|
|
506
|
+
catch (e) {
|
|
507
|
+
if (e?.code === 'NoSuchKey') {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
throw e;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
475
513
|
_ensureValidUrl(raw) {
|
|
476
514
|
const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
477
515
|
try {
|
|
@@ -677,32 +715,24 @@ class S3mini {
|
|
|
677
715
|
}
|
|
678
716
|
_extractBucketName() {
|
|
679
717
|
const url = this.endpoint;
|
|
680
|
-
//
|
|
681
|
-
const
|
|
682
|
-
if (
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
718
|
+
// Path-style: bucket is the first non-empty path segment
|
|
719
|
+
const firstSegment = url.pathname.split('/').find(Boolean);
|
|
720
|
+
if (firstSegment) {
|
|
721
|
+
return firstSegment;
|
|
722
|
+
}
|
|
723
|
+
// Virtual-hosted style: bucket is the first subdomain label
|
|
724
|
+
const hostname = url.hostname;
|
|
725
|
+
// IP addresses (v4: digits+dots, v6: contains colons) can't carry a bucket subdomain
|
|
726
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
|
|
727
|
+
return '';
|
|
686
728
|
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
//
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
// bucket-name.region.digitaloceanspaces.com
|
|
693
|
-
// bucket-name.region.cdn.digitaloceanspaces.com
|
|
694
|
-
if (hostParts.length >= 3) {
|
|
695
|
-
// Check if it's a known S3-compatible service
|
|
696
|
-
const domain = hostParts.slice(-2).join('.');
|
|
697
|
-
const knownDomains = ['amazonaws.com', 'digitaloceanspaces.com', 'cloudflare.com'];
|
|
698
|
-
if (knownDomains.some(d => domain.includes(d))) {
|
|
699
|
-
if (typeof hostParts[0] === 'string') {
|
|
700
|
-
return hostParts[0];
|
|
701
|
-
}
|
|
702
|
-
}
|
|
729
|
+
const labels = hostname.split('.');
|
|
730
|
+
// Need ≥3 labels for virtual-hosted (bucket.service.tld)
|
|
731
|
+
// Single-label (localhost) or two-label (example.com) have no room for a bucket subdomain
|
|
732
|
+
if (labels.length < 3) {
|
|
733
|
+
return '';
|
|
703
734
|
}
|
|
704
|
-
|
|
705
|
-
return hostParts[0] || '';
|
|
735
|
+
return labels[0];
|
|
706
736
|
}
|
|
707
737
|
/**
|
|
708
738
|
* Checks if a bucket exists.
|
|
@@ -732,6 +762,12 @@ class S3mini {
|
|
|
732
762
|
this._checkDelimiter(delimiter);
|
|
733
763
|
this._checkPrefix(prefix);
|
|
734
764
|
this._checkOpts(opts);
|
|
765
|
+
if (this._bun && delimiter === '/') {
|
|
766
|
+
const extraKeys = Object.keys(opts).filter(k => k !== 'delimiter');
|
|
767
|
+
if (extraKeys.length === 0) {
|
|
768
|
+
return this._bunListAll(prefix, maxKeys, opts.delimiter);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
735
771
|
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
736
772
|
const unlimited = !(maxKeys && maxKeys > 0);
|
|
737
773
|
let remaining = unlimited ? Infinity : maxKeys;
|
|
@@ -772,13 +808,17 @@ class S3mini {
|
|
|
772
808
|
this._checkOpts(opts);
|
|
773
809
|
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
774
810
|
let token = nextContinuationToken;
|
|
811
|
+
let remaining = maxKeys;
|
|
775
812
|
const all = [];
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
813
|
+
do {
|
|
814
|
+
const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
|
|
815
|
+
if (batchResult === null) {
|
|
816
|
+
return null; // 404 - bucket not found
|
|
817
|
+
}
|
|
818
|
+
all.push(...batchResult.objects);
|
|
819
|
+
remaining -= batchResult.objects.length;
|
|
820
|
+
token = batchResult.continuationToken;
|
|
821
|
+
} while (token && remaining > 0);
|
|
782
822
|
return { objects: all, nextContinuationToken: token };
|
|
783
823
|
}
|
|
784
824
|
async _fetchObjectBatch(keyPath, prefix, remaining, token, opts) {
|
|
@@ -873,6 +913,71 @@ class S3mini {
|
|
|
873
913
|
response.NextMarker ||
|
|
874
914
|
response.nextMarker);
|
|
875
915
|
}
|
|
916
|
+
async _bunListAll(prefix, maxKeys, delimiter) {
|
|
917
|
+
const unlimited = !(maxKeys && maxKeys > 0);
|
|
918
|
+
let remaining = unlimited ? Infinity : maxKeys;
|
|
919
|
+
let startAfter;
|
|
920
|
+
const all = [];
|
|
921
|
+
try {
|
|
922
|
+
do {
|
|
923
|
+
const batchSize = Math.min(remaining === Infinity ? 1000 : remaining, 1000);
|
|
924
|
+
const res = await this._bunFetchPage(prefix, delimiter, batchSize, startAfter);
|
|
925
|
+
const mapped = this._bunMapListResult(res);
|
|
926
|
+
all.push(...mapped);
|
|
927
|
+
if (!unlimited) {
|
|
928
|
+
remaining -= mapped.length;
|
|
929
|
+
}
|
|
930
|
+
startAfter = this._bunNextCursor(res);
|
|
931
|
+
} while (startAfter && remaining > 0);
|
|
932
|
+
}
|
|
933
|
+
catch (e) {
|
|
934
|
+
if (e?.code === 'NoSuchBucket') {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
throw e;
|
|
938
|
+
}
|
|
939
|
+
return all;
|
|
940
|
+
}
|
|
941
|
+
_bunFetchPage(prefix, delimiter, maxKeys, startAfter) {
|
|
942
|
+
return this._bun.list({
|
|
943
|
+
prefix: prefix || undefined,
|
|
944
|
+
delimiter: delimiter || '/',
|
|
945
|
+
maxKeys,
|
|
946
|
+
...(startAfter ? { startAfter } : {}),
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
_bunNextCursor(res) {
|
|
950
|
+
if (!res.isTruncated) {
|
|
951
|
+
return undefined;
|
|
952
|
+
}
|
|
953
|
+
return res.contents?.length ? res.contents.at(-1).key : undefined;
|
|
954
|
+
}
|
|
955
|
+
_bunMapListResult(res) {
|
|
956
|
+
const objects = [];
|
|
957
|
+
if (res.contents) {
|
|
958
|
+
for (const item of res.contents) {
|
|
959
|
+
objects.push({
|
|
960
|
+
Key: item.key,
|
|
961
|
+
Size: item.size,
|
|
962
|
+
LastModified: item.lastModified instanceof Date ? item.lastModified : new Date(item.lastModified),
|
|
963
|
+
ETag: item.etag ?? '',
|
|
964
|
+
StorageClass: item.storageClass ?? '',
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (res.commonPrefixes) {
|
|
969
|
+
for (const item of res.commonPrefixes) {
|
|
970
|
+
objects.push({
|
|
971
|
+
Key: item.prefix,
|
|
972
|
+
Size: 0,
|
|
973
|
+
LastModified: new Date(0),
|
|
974
|
+
ETag: '',
|
|
975
|
+
StorageClass: '',
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return objects;
|
|
980
|
+
}
|
|
876
981
|
/**
|
|
877
982
|
* Lists multipart uploads in the bucket.
|
|
878
983
|
* This method sends a request to list multipart uploads in the specified bucket.
|
|
@@ -919,7 +1024,9 @@ class S3mini {
|
|
|
919
1024
|
* @returns A promise that resolves to the object data (string) or null if not found.
|
|
920
1025
|
*/
|
|
921
1026
|
async getObject(key, opts = {}, ssecHeaders) {
|
|
922
|
-
|
|
1027
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1028
|
+
return this._bunRead(key, f => f.text());
|
|
1029
|
+
}
|
|
923
1030
|
const res = await this._signedRequest('GET', key, {
|
|
924
1031
|
query: opts, // use opts.query if it exists, otherwise use an empty object
|
|
925
1032
|
tolerated: [200, 404, 412, 304],
|
|
@@ -961,6 +1068,9 @@ class S3mini {
|
|
|
961
1068
|
* @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
|
|
962
1069
|
*/
|
|
963
1070
|
async getObjectArrayBuffer(key, opts = {}, ssecHeaders) {
|
|
1071
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1072
|
+
return this._bunRead(key, f => f.arrayBuffer());
|
|
1073
|
+
}
|
|
964
1074
|
const res = await this._signedRequest('GET', key, {
|
|
965
1075
|
query: opts,
|
|
966
1076
|
tolerated: [200, 404, 412, 304],
|
|
@@ -981,6 +1091,9 @@ class S3mini {
|
|
|
981
1091
|
* @returns A promise that resolves to the object data as JSON or null if not found.
|
|
982
1092
|
*/
|
|
983
1093
|
async getObjectJSON(key, opts = {}, ssecHeaders) {
|
|
1094
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1095
|
+
return this._bunRead(key, f => f.json());
|
|
1096
|
+
}
|
|
984
1097
|
const res = await this._signedRequest('GET', key, {
|
|
985
1098
|
query: opts,
|
|
986
1099
|
tolerated: [200, 404, 412, 304],
|
|
@@ -1001,6 +1114,19 @@ class S3mini {
|
|
|
1001
1114
|
* @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
|
|
1002
1115
|
*/
|
|
1003
1116
|
async getObjectWithETag(key, opts = {}, ssecHeaders) {
|
|
1117
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1118
|
+
try {
|
|
1119
|
+
const f = this._bun.file(key);
|
|
1120
|
+
const [stat, data] = await Promise.all([f.stat(), f.arrayBuffer()]);
|
|
1121
|
+
return { etag: sanitizeETag(stat.etag), data };
|
|
1122
|
+
}
|
|
1123
|
+
catch (e) {
|
|
1124
|
+
if (e?.code === 'NoSuchKey') {
|
|
1125
|
+
return { etag: null, data: null };
|
|
1126
|
+
}
|
|
1127
|
+
throw e;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1004
1130
|
try {
|
|
1005
1131
|
const res = await this._signedRequest('GET', key, {
|
|
1006
1132
|
query: opts,
|
|
@@ -1035,6 +1161,28 @@ class S3mini {
|
|
|
1035
1161
|
* @returns A promise that resolves to the Response object.
|
|
1036
1162
|
*/
|
|
1037
1163
|
async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo, opts = {}, ssecHeaders) {
|
|
1164
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1165
|
+
const f = this._bun.file(key);
|
|
1166
|
+
if (wholeFile) {
|
|
1167
|
+
const buf = await f.arrayBuffer();
|
|
1168
|
+
const stat = await f.stat();
|
|
1169
|
+
return new Response(buf, {
|
|
1170
|
+
status: 200,
|
|
1171
|
+
headers: { 'content-length': String(stat.size), etag: stat.etag, 'content-type': stat.type },
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
const sliced = rangeTo === undefined ? f.slice(rangeFrom) : f.slice(rangeFrom, rangeTo);
|
|
1175
|
+
const buf = await sliced.arrayBuffer();
|
|
1176
|
+
const stat = await f.stat();
|
|
1177
|
+
const endByte = rangeTo === undefined ? stat.size - 1 : rangeTo - 1;
|
|
1178
|
+
return new Response(buf, {
|
|
1179
|
+
status: 206,
|
|
1180
|
+
headers: {
|
|
1181
|
+
'content-range': `bytes ${rangeFrom}-${endByte}/${stat.size}`,
|
|
1182
|
+
'content-length': String(buf.byteLength),
|
|
1183
|
+
},
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1038
1186
|
let rangeHdr = {};
|
|
1039
1187
|
if (!wholeFile) {
|
|
1040
1188
|
rangeHdr =
|
|
@@ -1055,6 +1203,9 @@ class S3mini {
|
|
|
1055
1203
|
*/
|
|
1056
1204
|
async getContentLength(key, ssecHeaders) {
|
|
1057
1205
|
try {
|
|
1206
|
+
if (this._bun && !ssecHeaders) {
|
|
1207
|
+
return (await this._bun.file(key).stat()).size;
|
|
1208
|
+
}
|
|
1058
1209
|
const res = await this._signedRequest('HEAD', key, {
|
|
1059
1210
|
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
1060
1211
|
});
|
|
@@ -1076,6 +1227,9 @@ class S3mini {
|
|
|
1076
1227
|
* @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
|
|
1077
1228
|
*/
|
|
1078
1229
|
async objectExists(key, opts = {}) {
|
|
1230
|
+
if (this._bun && !Object.keys(opts).length) {
|
|
1231
|
+
return this._bun.file(key).exists();
|
|
1232
|
+
}
|
|
1079
1233
|
const res = await this._signedRequest('HEAD', key, {
|
|
1080
1234
|
query: opts,
|
|
1081
1235
|
tolerated: [200, 404, 412, 304],
|
|
@@ -1102,6 +1256,15 @@ class S3mini {
|
|
|
1102
1256
|
* }
|
|
1103
1257
|
*/
|
|
1104
1258
|
async getEtag(key, opts = {}, ssecHeaders) {
|
|
1259
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1260
|
+
return this._bunRead(key, async (f) => {
|
|
1261
|
+
const { etag } = await f.stat();
|
|
1262
|
+
if (!etag) {
|
|
1263
|
+
throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
|
|
1264
|
+
}
|
|
1265
|
+
return sanitizeETag(etag);
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1105
1268
|
const res = await this._signedRequest('HEAD', key, {
|
|
1106
1269
|
query: opts,
|
|
1107
1270
|
tolerated: [200, 304, 404, 412],
|
|
@@ -1137,6 +1300,12 @@ class S3mini {
|
|
|
1137
1300
|
* await s3.putObject('image.png', buffer, 'image/png');
|
|
1138
1301
|
*/
|
|
1139
1302
|
async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders, additionalHeaders, contentLength) {
|
|
1303
|
+
if (this._bun && !ssecHeaders && !additionalHeaders) {
|
|
1304
|
+
const f = this._bun.file(key);
|
|
1305
|
+
await f.write(data, { type: fileType });
|
|
1306
|
+
const { etag } = await f.stat();
|
|
1307
|
+
return new Response(null, { status: 200, headers: etag ? { etag } : {} });
|
|
1308
|
+
}
|
|
1140
1309
|
const size = contentLength ?? getByteSize(data);
|
|
1141
1310
|
return this._signedRequest('PUT', key, {
|
|
1142
1311
|
body: data,
|
|
@@ -1169,6 +1338,14 @@ class S3mini {
|
|
|
1169
1338
|
* await s3.putAnyObject('image.png', buffer, 'image/png');
|
|
1170
1339
|
*/
|
|
1171
1340
|
async putAnyObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders, additionalHeaders, contentLength) {
|
|
1341
|
+
// Bun handles multipart automatically for large files
|
|
1342
|
+
if (this._bun && !ssecHeaders && !additionalHeaders) {
|
|
1343
|
+
this._checkKey(key);
|
|
1344
|
+
const f = this._bun.file(key);
|
|
1345
|
+
await f.write(data, { type: fileType });
|
|
1346
|
+
const { etag } = await f.stat();
|
|
1347
|
+
return this._createSuccessResponse(etag || '');
|
|
1348
|
+
}
|
|
1172
1349
|
const size = contentLength ?? getByteSize(data);
|
|
1173
1350
|
// Single PUT for small files
|
|
1174
1351
|
if (!Number.isNaN(size) && size <= this.minPartSize) {
|
|
@@ -1631,72 +1808,74 @@ class S3mini {
|
|
|
1631
1808
|
* @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
|
|
1632
1809
|
*/
|
|
1633
1810
|
async deleteObject(key) {
|
|
1811
|
+
if (this._bun) {
|
|
1812
|
+
await this._bun.file(key).delete();
|
|
1813
|
+
return true;
|
|
1814
|
+
}
|
|
1634
1815
|
const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
|
|
1635
1816
|
return res.status === 200 || res.status === 204;
|
|
1636
1817
|
}
|
|
1637
1818
|
async _deleteObjectsProcess(keys) {
|
|
1819
|
+
const out = await this._sendDeleteRequest(keys);
|
|
1820
|
+
const resultMap = new Map(keys.map(k => [k, false]));
|
|
1821
|
+
this._markDeletedKeys(out, resultMap);
|
|
1822
|
+
this._logDeleteErrors(out, resultMap);
|
|
1823
|
+
return keys.map(key => resultMap.get(key) || false);
|
|
1824
|
+
}
|
|
1825
|
+
async _sendDeleteRequest(keys) {
|
|
1638
1826
|
const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
|
|
1639
1827
|
const xmlBody = '<Delete>' + objectsXml + '</Delete>';
|
|
1640
|
-
const query = { delete: '' };
|
|
1641
1828
|
const sha256base64 = base64FromBuffer(await sha256(xmlBody));
|
|
1642
|
-
const headers = {
|
|
1643
|
-
[HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
|
|
1644
|
-
[HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1645
|
-
[HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1646
|
-
};
|
|
1647
1829
|
const res = await this._signedRequest('POST', '', {
|
|
1648
|
-
query,
|
|
1830
|
+
query: { delete: '' },
|
|
1649
1831
|
body: xmlBody,
|
|
1650
|
-
headers
|
|
1832
|
+
headers: {
|
|
1833
|
+
[HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
|
|
1834
|
+
[HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1835
|
+
[HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1836
|
+
},
|
|
1651
1837
|
withQuery: true,
|
|
1652
1838
|
});
|
|
1653
1839
|
const parsed = parseXml(await res.text());
|
|
1654
1840
|
if (!parsed || typeof parsed !== 'object') {
|
|
1655
1841
|
throw new Error(`${ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
|
|
1656
1842
|
}
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
resultMap.set(key, false);
|
|
1661
|
-
}
|
|
1843
|
+
return (parsed.DeleteResult || parsed.deleteResult || parsed);
|
|
1844
|
+
}
|
|
1845
|
+
_markDeletedKeys(out, resultMap) {
|
|
1662
1846
|
const deleted = out.deleted || out.Deleted;
|
|
1663
|
-
if (deleted) {
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
}
|
|
1847
|
+
if (!deleted) {
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
const items = Array.isArray(deleted) ? deleted : [deleted];
|
|
1851
|
+
for (const item of items) {
|
|
1852
|
+
if (item && typeof item === 'object') {
|
|
1853
|
+
const key = item.key || item.Key;
|
|
1854
|
+
if (key && typeof key === 'string') {
|
|
1855
|
+
resultMap.set(key, true);
|
|
1673
1856
|
}
|
|
1674
1857
|
}
|
|
1675
1858
|
}
|
|
1676
|
-
|
|
1859
|
+
}
|
|
1860
|
+
_logDeleteErrors(out, resultMap) {
|
|
1677
1861
|
const errors = out.error || out.Error;
|
|
1678
|
-
if (errors) {
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
message: message || 'Unknown error',
|
|
1693
|
-
});
|
|
1694
|
-
}
|
|
1862
|
+
if (!errors) {
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
const items = Array.isArray(errors) ? errors : [errors];
|
|
1866
|
+
for (const item of items) {
|
|
1867
|
+
if (item && typeof item === 'object') {
|
|
1868
|
+
const obj = item;
|
|
1869
|
+
const key = obj.key || obj.Key;
|
|
1870
|
+
if (key && typeof key === 'string') {
|
|
1871
|
+
resultMap.set(key, false);
|
|
1872
|
+
this._log('warn', `Failed to delete object: ${key}`, {
|
|
1873
|
+
code: obj.code || obj.Code || 'Unknown',
|
|
1874
|
+
message: obj.message || obj.Message || 'Unknown error',
|
|
1875
|
+
});
|
|
1695
1876
|
}
|
|
1696
1877
|
}
|
|
1697
1878
|
}
|
|
1698
|
-
// Return boolean array in the same order as input keys
|
|
1699
|
-
return keys.map(key => resultMap.get(key) || false);
|
|
1700
1879
|
}
|
|
1701
1880
|
/**
|
|
1702
1881
|
* Deletes multiple objects from the bucket.
|
|
@@ -1789,27 +1968,34 @@ class S3mini {
|
|
|
1789
1968
|
* @param {'GET' | 'PUT'} method - HTTP method ('GET' for download, 'PUT' for upload)
|
|
1790
1969
|
* @param {string} key - The object key/path
|
|
1791
1970
|
* @param {number} [expiresIn=3600] - URL expiration time in seconds (1–604800)
|
|
1792
|
-
* @param {Record<string, string>} [queryParams={}] - Additional query parameters to
|
|
1971
|
+
* @param {Record<string, string>} [queryParams={}] - Additional query parameters to include in the URL
|
|
1972
|
+
* @param {Record<string, string>} [headers={}] - HTTP headers to sign. The consumer of the URL
|
|
1973
|
+
* MUST send these exact headers with matching values. The `host` header is always signed automatically.
|
|
1793
1974
|
* @returns {Promise<string>} Pre-signed URL string
|
|
1794
1975
|
* @throws {TypeError} If key is empty or expiresIn is out of range
|
|
1795
1976
|
* @example
|
|
1796
1977
|
* // Download URL valid for 1 hour
|
|
1797
1978
|
* const url = await s3.getPresignedUrl('GET', 'photos/vacation.jpg');
|
|
1798
1979
|
*
|
|
1799
|
-
* // Upload URL valid for 5 minutes
|
|
1800
|
-
* const url = await s3.getPresignedUrl('PUT', 'uploads/file.bin', 300
|
|
1980
|
+
* // Upload URL valid for 5 minutes with signed Content-Type
|
|
1981
|
+
* const url = await s3.getPresignedUrl('PUT', 'uploads/file.bin', 300, {}, {
|
|
1982
|
+
* 'Content-Type': 'application/octet-stream',
|
|
1983
|
+
* });
|
|
1801
1984
|
*
|
|
1802
|
-
* // Client-side usage (
|
|
1803
|
-
* await fetch(url, { method: 'PUT', body: data });
|
|
1985
|
+
* // Client-side usage (must include signed headers)
|
|
1986
|
+
* await fetch(url, { method: 'PUT', body: data, headers: { 'Content-Type': 'application/octet-stream' } });
|
|
1804
1987
|
*/
|
|
1805
|
-
async getPresignedUrl(method, key, expiresIn = 3600, queryParams = {}) {
|
|
1988
|
+
async getPresignedUrl(method, key, expiresIn = 3600, queryParams = {}, headers = {}) {
|
|
1806
1989
|
this._checkKey(key);
|
|
1807
1990
|
if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
|
|
1808
1991
|
throw new TypeError(`${ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
|
|
1809
1992
|
}
|
|
1810
|
-
|
|
1993
|
+
if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
|
|
1994
|
+
return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
|
|
1995
|
+
}
|
|
1996
|
+
return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
|
|
1811
1997
|
}
|
|
1812
|
-
async _presign(method, keyPath, expiresIn, queryParams) {
|
|
1998
|
+
async _presign(method, keyPath, expiresIn, queryParams, headers) {
|
|
1813
1999
|
const url = new URL(this.endpoint);
|
|
1814
2000
|
if (keyPath.length > 0) {
|
|
1815
2001
|
url.pathname =
|
|
@@ -1822,7 +2008,16 @@ class S3mini {
|
|
|
1822
2008
|
const shortDatetime = `${year}${month}${day}`;
|
|
1823
2009
|
const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
|
|
1824
2010
|
const credentialScope = `${shortDatetime}/${this.region}/${S3_SERVICE}/${AWS_REQUEST_TYPE}`;
|
|
1825
|
-
const
|
|
2011
|
+
const headerEntries = [['host', url.host]];
|
|
2012
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
2013
|
+
const lowerKey = key.toLowerCase();
|
|
2014
|
+
if (lowerKey !== 'host') {
|
|
2015
|
+
headerEntries.push([lowerKey, String(value).trim()]);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
headerEntries.sort(([a], [b]) => a.localeCompare(b));
|
|
2019
|
+
const canonicalHeaders = headerEntries.map(([k, v]) => `${k}:${v}`).join('\n');
|
|
2020
|
+
const signedHeaders = headerEntries.map(([k]) => k).join(';');
|
|
1826
2021
|
const allQueryParams = {
|
|
1827
2022
|
...queryParams,
|
|
1828
2023
|
'X-Amz-Algorithm': AWS_ALGORITHM,
|
|
@@ -1832,7 +2027,7 @@ class S3mini {
|
|
|
1832
2027
|
'X-Amz-SignedHeaders': signedHeaders,
|
|
1833
2028
|
};
|
|
1834
2029
|
const canonicalQueryString = this._buildCanonicalQueryString(allQueryParams);
|
|
1835
|
-
const canonicalRequest = `${method}\n${url.pathname}\n${canonicalQueryString}\
|
|
2030
|
+
const canonicalRequest = `${method}\n${url.pathname}\n${canonicalQueryString}\n${canonicalHeaders}\n\n${signedHeaders}\n${UNSIGNED_PAYLOAD}`;
|
|
1836
2031
|
const stringToSign = `${AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
|
|
1837
2032
|
if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
|
|
1838
2033
|
this.signingKeyDate = shortDatetime;
|