s3mini 0.9.3 → 0.9.5
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 +35 -22
- package/dist/s3mini.d.ts +10 -0
- package/dist/s3mini.js +266 -84
- 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 +16 -12
- package/src/S3.ts +275 -86
- package/src/types.ts +54 -0
- package/src/utils.ts +28 -0
package/src/S3.ts
CHANGED
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
S3ServiceError,
|
|
18
18
|
generateParts,
|
|
19
19
|
toUint8Array,
|
|
20
|
+
isBun,
|
|
21
|
+
extractBaseEndpoint,
|
|
22
|
+
byCodePoint,
|
|
20
23
|
} from './utils.js';
|
|
21
24
|
import type * as IT from './types.js';
|
|
22
25
|
|
|
@@ -70,6 +73,7 @@ class S3mini {
|
|
|
70
73
|
readonly logger?: IT.Logger;
|
|
71
74
|
readonly _fetch: typeof fetch;
|
|
72
75
|
readonly minPartSize: number;
|
|
76
|
+
private readonly _bun?: IT.NativeS3Client;
|
|
73
77
|
private signingKeyDate?: string;
|
|
74
78
|
private signingKey?: ArrayBuffer;
|
|
75
79
|
|
|
@@ -95,6 +99,19 @@ class S3mini {
|
|
|
95
99
|
this.logger = logger;
|
|
96
100
|
this._fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => fetch(input, init);
|
|
97
101
|
this.minPartSize = minPartSize;
|
|
102
|
+
|
|
103
|
+
if (isBun) {
|
|
104
|
+
const { S3Client } = (
|
|
105
|
+
globalThis as unknown as { Bun: { S3Client: new (o: Record<string, unknown>) => IT.NativeS3Client } }
|
|
106
|
+
).Bun;
|
|
107
|
+
this._bun = new S3Client({
|
|
108
|
+
accessKeyId,
|
|
109
|
+
secretAccessKey,
|
|
110
|
+
endpoint: extractBaseEndpoint(this.endpoint, this.bucketName),
|
|
111
|
+
region: this.region,
|
|
112
|
+
bucket: this.bucketName,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
98
115
|
}
|
|
99
116
|
|
|
100
117
|
private _sanitize(obj: unknown): unknown {
|
|
@@ -169,6 +186,18 @@ class S3mini {
|
|
|
169
186
|
return this.#accessKeyId.trim().length > 0 && this.#secretAccessKey.trim().length > 0;
|
|
170
187
|
}
|
|
171
188
|
|
|
189
|
+
/** Run a read op via Bun-native S3, returning null on NoSuchKey. */
|
|
190
|
+
private async _bunRead<T>(key: string, op: (f: IT.NativeS3File) => Promise<T>): Promise<T | null> {
|
|
191
|
+
try {
|
|
192
|
+
return await op(this._bun!.file(key));
|
|
193
|
+
} catch (e) {
|
|
194
|
+
if ((e as { code?: string })?.code === 'NoSuchKey') {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
172
201
|
private _ensureValidUrl(raw: string): string {
|
|
173
202
|
const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
174
203
|
try {
|
|
@@ -317,20 +346,13 @@ class S3mini {
|
|
|
317
346
|
|
|
318
347
|
const ignoredHeaders = new Set(['authorization', 'content-length', 'content-type', 'user-agent']);
|
|
319
348
|
|
|
320
|
-
|
|
321
|
-
|
|
349
|
+
const sortedHeaders = Object.entries(headers)
|
|
350
|
+
.map(([key, value]): [string, string] => [key.toLowerCase(), String(value).trim()])
|
|
351
|
+
.filter(([lowerKey]) => !ignoredHeaders.has(lowerKey))
|
|
352
|
+
.sort(([a], [b]) => byCodePoint(a, b));
|
|
322
353
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (!ignoredHeaders.has(lowerKey)) {
|
|
326
|
-
if (canonicalHeaders) {
|
|
327
|
-
canonicalHeaders += '\n';
|
|
328
|
-
signedHeaders += ';';
|
|
329
|
-
}
|
|
330
|
-
canonicalHeaders += `${lowerKey}:${String(value).trim()}`;
|
|
331
|
-
signedHeaders += lowerKey;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
354
|
+
const canonicalHeaders = sortedHeaders.map(([k, v]) => `${k}:${v}`).join('\n');
|
|
355
|
+
const signedHeaders = sortedHeaders.map(([k]) => k).join(';');
|
|
334
356
|
const canonicalRequest = `${method}\n${url.pathname}\n${this._buildCanonicalQueryString(query)}\n${canonicalHeaders}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
|
|
335
357
|
const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
|
|
336
358
|
if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
|
|
@@ -426,37 +448,29 @@ class S3mini {
|
|
|
426
448
|
private _extractBucketName(): string {
|
|
427
449
|
const url = this.endpoint;
|
|
428
450
|
|
|
429
|
-
//
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
432
|
-
|
|
433
|
-
return pathSegments[0];
|
|
434
|
-
}
|
|
451
|
+
// Path-style: bucket is the first non-empty path segment
|
|
452
|
+
const firstSegment = url.pathname.split('/').find(Boolean);
|
|
453
|
+
if (firstSegment) {
|
|
454
|
+
return firstSegment;
|
|
435
455
|
}
|
|
436
456
|
|
|
437
|
-
//
|
|
438
|
-
const
|
|
457
|
+
// Virtual-hosted style: bucket is the first subdomain label
|
|
458
|
+
const hostname = url.hostname;
|
|
439
459
|
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// bucket-name.region.cdn.digitaloceanspaces.com
|
|
460
|
+
// IP addresses (v4: digits+dots, v6: contains colons) can't carry a bucket subdomain
|
|
461
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
|
|
462
|
+
return '';
|
|
463
|
+
}
|
|
445
464
|
|
|
446
|
-
|
|
447
|
-
// Check if it's a known S3-compatible service
|
|
448
|
-
const domain = hostParts.slice(-2).join('.');
|
|
449
|
-
const knownDomains = ['amazonaws.com', 'digitaloceanspaces.com', 'cloudflare.com'];
|
|
465
|
+
const labels = hostname.split('.');
|
|
450
466
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
467
|
+
// Need ≥3 labels for virtual-hosted (bucket.service.tld)
|
|
468
|
+
// Single-label (localhost) or two-label (example.com) have no room for a bucket subdomain
|
|
469
|
+
if (labels.length < 3) {
|
|
470
|
+
return '';
|
|
456
471
|
}
|
|
457
472
|
|
|
458
|
-
|
|
459
|
-
return hostParts[0] || '';
|
|
473
|
+
return labels[0]!;
|
|
460
474
|
}
|
|
461
475
|
|
|
462
476
|
/**
|
|
@@ -494,6 +508,13 @@ class S3mini {
|
|
|
494
508
|
this._checkPrefix(prefix);
|
|
495
509
|
this._checkOpts(opts);
|
|
496
510
|
|
|
511
|
+
if (this._bun && delimiter === '/') {
|
|
512
|
+
const extraKeys = Object.keys(opts).filter(k => k !== 'delimiter');
|
|
513
|
+
if (extraKeys.length === 0) {
|
|
514
|
+
return this._bunListAll(prefix, maxKeys, opts.delimiter as string | undefined);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
497
518
|
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
498
519
|
const unlimited = !(maxKeys && maxKeys > 0);
|
|
499
520
|
let remaining = unlimited ? Infinity : maxKeys;
|
|
@@ -698,6 +719,90 @@ class S3mini {
|
|
|
698
719
|
response.nextMarker) as string | undefined;
|
|
699
720
|
}
|
|
700
721
|
|
|
722
|
+
private async _bunListAll(
|
|
723
|
+
prefix: string,
|
|
724
|
+
maxKeys: number | undefined,
|
|
725
|
+
delimiter: string | undefined,
|
|
726
|
+
): Promise<IT.ListObject[] | null> {
|
|
727
|
+
const unlimited = !(maxKeys && maxKeys > 0);
|
|
728
|
+
let remaining = unlimited ? Infinity : maxKeys;
|
|
729
|
+
let startAfter: string | undefined;
|
|
730
|
+
const all: IT.ListObject[] = [];
|
|
731
|
+
|
|
732
|
+
try {
|
|
733
|
+
do {
|
|
734
|
+
const batchSize = Math.min(remaining === Infinity ? 1000 : remaining, 1000);
|
|
735
|
+
const res = await this._bunFetchPage(prefix, delimiter, batchSize, startAfter);
|
|
736
|
+
const mapped = this._bunMapListResult(res);
|
|
737
|
+
all.push(...mapped);
|
|
738
|
+
|
|
739
|
+
if (!unlimited) {
|
|
740
|
+
remaining -= mapped.length;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
startAfter = this._bunNextCursor(res);
|
|
744
|
+
} while (startAfter && remaining > 0);
|
|
745
|
+
} catch (e) {
|
|
746
|
+
if ((e as { code?: string })?.code === 'NoSuchBucket') {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
throw e;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return all;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private _bunFetchPage(
|
|
756
|
+
prefix: string,
|
|
757
|
+
delimiter: string | undefined,
|
|
758
|
+
maxKeys: number,
|
|
759
|
+
startAfter?: string,
|
|
760
|
+
): Promise<IT.NativeS3ListResult> {
|
|
761
|
+
return this._bun!.list({
|
|
762
|
+
prefix: prefix || undefined,
|
|
763
|
+
delimiter: delimiter || '/',
|
|
764
|
+
maxKeys,
|
|
765
|
+
...(startAfter ? { startAfter } : {}),
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private _bunNextCursor(res: IT.NativeS3ListResult): string | undefined {
|
|
770
|
+
if (!res.isTruncated) {
|
|
771
|
+
return undefined;
|
|
772
|
+
}
|
|
773
|
+
return res.contents?.length ? res.contents.at(-1)!.key : undefined;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private _bunMapListResult(res: IT.NativeS3ListResult): IT.ListObject[] {
|
|
777
|
+
const objects: IT.ListObject[] = [];
|
|
778
|
+
|
|
779
|
+
if (res.contents) {
|
|
780
|
+
for (const item of res.contents) {
|
|
781
|
+
objects.push({
|
|
782
|
+
Key: item.key,
|
|
783
|
+
Size: item.size,
|
|
784
|
+
LastModified: item.lastModified instanceof Date ? item.lastModified : new Date(item.lastModified),
|
|
785
|
+
ETag: item.etag ?? '',
|
|
786
|
+
StorageClass: item.storageClass ?? '',
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (res.commonPrefixes) {
|
|
792
|
+
for (const item of res.commonPrefixes) {
|
|
793
|
+
objects.push({
|
|
794
|
+
Key: item.prefix,
|
|
795
|
+
Size: 0,
|
|
796
|
+
LastModified: new Date(0),
|
|
797
|
+
ETag: '',
|
|
798
|
+
StorageClass: '',
|
|
799
|
+
} as IT.ListObject);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return objects;
|
|
804
|
+
}
|
|
805
|
+
|
|
701
806
|
/**
|
|
702
807
|
* Lists multipart uploads in the bucket.
|
|
703
808
|
* This method sends a request to list multipart uploads in the specified bucket.
|
|
@@ -756,7 +861,9 @@ class S3mini {
|
|
|
756
861
|
opts: Record<string, unknown> = {},
|
|
757
862
|
ssecHeaders?: IT.SSECHeaders,
|
|
758
863
|
): Promise<string | null> {
|
|
759
|
-
|
|
864
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
865
|
+
return this._bunRead(key, f => f.text());
|
|
866
|
+
}
|
|
760
867
|
const res = await this._signedRequest('GET', key, {
|
|
761
868
|
query: opts, // use opts.query if it exists, otherwise use an empty object
|
|
762
869
|
tolerated: [200, 404, 412, 304],
|
|
@@ -808,6 +915,9 @@ class S3mini {
|
|
|
808
915
|
opts: Record<string, unknown> = {},
|
|
809
916
|
ssecHeaders?: IT.SSECHeaders,
|
|
810
917
|
): Promise<ArrayBuffer | null> {
|
|
918
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
919
|
+
return this._bunRead(key, f => f.arrayBuffer());
|
|
920
|
+
}
|
|
811
921
|
const res = await this._signedRequest('GET', key, {
|
|
812
922
|
query: opts,
|
|
813
923
|
tolerated: [200, 404, 412, 304],
|
|
@@ -833,6 +943,9 @@ class S3mini {
|
|
|
833
943
|
opts: Record<string, unknown> = {},
|
|
834
944
|
ssecHeaders?: IT.SSECHeaders,
|
|
835
945
|
): Promise<T | null> {
|
|
946
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
947
|
+
return this._bunRead(key, f => f.json()) as Promise<T | null>;
|
|
948
|
+
}
|
|
836
949
|
const res = await this._signedRequest('GET', key, {
|
|
837
950
|
query: opts,
|
|
838
951
|
tolerated: [200, 404, 412, 304],
|
|
@@ -858,6 +971,18 @@ class S3mini {
|
|
|
858
971
|
opts: Record<string, unknown> = {},
|
|
859
972
|
ssecHeaders?: IT.SSECHeaders,
|
|
860
973
|
): Promise<{ etag: string | null; data: ArrayBuffer | null }> {
|
|
974
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
975
|
+
try {
|
|
976
|
+
const f = this._bun.file(key);
|
|
977
|
+
const [stat, data] = await Promise.all([f.stat(), f.arrayBuffer()]);
|
|
978
|
+
return { etag: sanitizeETag(stat.etag), data };
|
|
979
|
+
} catch (e) {
|
|
980
|
+
if ((e as { code?: string })?.code === 'NoSuchKey') {
|
|
981
|
+
return { etag: null, data: null };
|
|
982
|
+
}
|
|
983
|
+
throw e;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
861
986
|
try {
|
|
862
987
|
const res = await this._signedRequest('GET', key, {
|
|
863
988
|
query: opts,
|
|
@@ -900,6 +1025,29 @@ class S3mini {
|
|
|
900
1025
|
opts: Record<string, unknown> = {},
|
|
901
1026
|
ssecHeaders?: IT.SSECHeaders,
|
|
902
1027
|
): Promise<Response> {
|
|
1028
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1029
|
+
const f = this._bun.file(key);
|
|
1030
|
+
if (wholeFile) {
|
|
1031
|
+
const buf = await f.arrayBuffer();
|
|
1032
|
+
const stat = await f.stat();
|
|
1033
|
+
return new Response(buf, {
|
|
1034
|
+
status: 200,
|
|
1035
|
+
headers: { 'content-length': String(stat.size), etag: stat.etag, 'content-type': stat.type },
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
const sliced = rangeTo === undefined ? f.slice(rangeFrom) : f.slice(rangeFrom, rangeTo);
|
|
1039
|
+
const buf = await sliced.arrayBuffer();
|
|
1040
|
+
const stat = await f.stat();
|
|
1041
|
+
const endByte = rangeTo === undefined ? stat.size - 1 : rangeTo - 1;
|
|
1042
|
+
return new Response(buf, {
|
|
1043
|
+
status: 206,
|
|
1044
|
+
headers: {
|
|
1045
|
+
'content-range': `bytes ${rangeFrom}-${endByte}/${stat.size}`,
|
|
1046
|
+
'content-length': String(buf.byteLength),
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
|
|
903
1051
|
let rangeHdr: Record<string, string | number> = {};
|
|
904
1052
|
|
|
905
1053
|
if (!wholeFile) {
|
|
@@ -922,6 +1070,9 @@ class S3mini {
|
|
|
922
1070
|
*/
|
|
923
1071
|
public async getContentLength(key: string, ssecHeaders?: IT.SSECHeaders): Promise<number> {
|
|
924
1072
|
try {
|
|
1073
|
+
if (this._bun && !ssecHeaders) {
|
|
1074
|
+
return (await this._bun.file(key).stat()).size;
|
|
1075
|
+
}
|
|
925
1076
|
const res = await this._signedRequest('HEAD', key, {
|
|
926
1077
|
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
927
1078
|
});
|
|
@@ -943,6 +1094,9 @@ class S3mini {
|
|
|
943
1094
|
* @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
|
|
944
1095
|
*/
|
|
945
1096
|
public async objectExists(key: string, opts: Record<string, unknown> = {}): Promise<IT.ExistResponseCode> {
|
|
1097
|
+
if (this._bun && !Object.keys(opts).length) {
|
|
1098
|
+
return this._bun.file(key).exists();
|
|
1099
|
+
}
|
|
946
1100
|
const res = await this._signedRequest('HEAD', key, {
|
|
947
1101
|
query: opts,
|
|
948
1102
|
tolerated: [200, 404, 412, 304],
|
|
@@ -975,6 +1129,15 @@ class S3mini {
|
|
|
975
1129
|
opts: Record<string, unknown> = {},
|
|
976
1130
|
ssecHeaders?: IT.SSECHeaders,
|
|
977
1131
|
): Promise<string | null> {
|
|
1132
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1133
|
+
return this._bunRead(key, async f => {
|
|
1134
|
+
const { etag } = await f.stat();
|
|
1135
|
+
if (!etag) {
|
|
1136
|
+
throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
|
|
1137
|
+
}
|
|
1138
|
+
return sanitizeETag(etag);
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
978
1141
|
const res = await this._signedRequest('HEAD', key, {
|
|
979
1142
|
query: opts,
|
|
980
1143
|
tolerated: [200, 304, 404, 412],
|
|
@@ -1022,6 +1185,12 @@ class S3mini {
|
|
|
1022
1185
|
additionalHeaders?: IT.AWSHeaders,
|
|
1023
1186
|
contentLength?: number,
|
|
1024
1187
|
): Promise<Response> {
|
|
1188
|
+
if (this._bun && !ssecHeaders && !additionalHeaders) {
|
|
1189
|
+
const f = this._bun.file(key);
|
|
1190
|
+
await f.write(data as string | ArrayBuffer | Uint8Array | Blob | ReadableStream, { type: fileType });
|
|
1191
|
+
const { etag } = await f.stat();
|
|
1192
|
+
return new Response(null, { status: 200, headers: etag ? { etag } : {} });
|
|
1193
|
+
}
|
|
1025
1194
|
const size = contentLength ?? getByteSize(data);
|
|
1026
1195
|
return this._signedRequest('PUT', key, {
|
|
1027
1196
|
body: data as BodyInit,
|
|
@@ -1062,6 +1231,15 @@ class S3mini {
|
|
|
1062
1231
|
additionalHeaders?: IT.AWSHeaders,
|
|
1063
1232
|
contentLength?: number,
|
|
1064
1233
|
): Promise<Response | { ok: boolean; status: number; headers: Map<string, string> }> {
|
|
1234
|
+
// Bun handles multipart automatically for large files
|
|
1235
|
+
if (this._bun && !ssecHeaders && !additionalHeaders) {
|
|
1236
|
+
this._checkKey(key);
|
|
1237
|
+
const f = this._bun.file(key);
|
|
1238
|
+
await f.write(data as string | ArrayBuffer | Uint8Array | Blob | ReadableStream, { type: fileType });
|
|
1239
|
+
const { etag } = await f.stat();
|
|
1240
|
+
return this._createSuccessResponse(etag || '');
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1065
1243
|
const size = contentLength ?? getByteSize(data);
|
|
1066
1244
|
|
|
1067
1245
|
// Single PUT for small files
|
|
@@ -1697,77 +1875,84 @@ class S3mini {
|
|
|
1697
1875
|
* @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
|
|
1698
1876
|
*/
|
|
1699
1877
|
public async deleteObject(key: string): Promise<boolean> {
|
|
1878
|
+
if (this._bun) {
|
|
1879
|
+
await this._bun.file(key).delete();
|
|
1880
|
+
return true;
|
|
1881
|
+
}
|
|
1700
1882
|
const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
|
|
1701
1883
|
return res.status === 200 || res.status === 204;
|
|
1702
1884
|
}
|
|
1703
1885
|
|
|
1704
1886
|
private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> {
|
|
1887
|
+
const out = await this._sendDeleteRequest(keys);
|
|
1888
|
+
|
|
1889
|
+
const resultMap = new Map<string, boolean>(keys.map(k => [k, false]));
|
|
1890
|
+
this._markDeletedKeys(out, resultMap);
|
|
1891
|
+
this._logDeleteErrors(out, resultMap);
|
|
1892
|
+
|
|
1893
|
+
return keys.map(key => resultMap.get(key) || false);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
private async _sendDeleteRequest(keys: string[]): Promise<Record<string, unknown>> {
|
|
1705
1897
|
const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
|
|
1706
1898
|
const xmlBody = '<Delete>' + objectsXml + '</Delete>';
|
|
1707
|
-
const query = { delete: '' };
|
|
1708
1899
|
const sha256base64 = base64FromBuffer(await sha256(xmlBody));
|
|
1709
|
-
const headers = {
|
|
1710
|
-
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
1711
|
-
[C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1712
|
-
[C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1713
|
-
};
|
|
1714
1900
|
|
|
1715
1901
|
const res = await this._signedRequest('POST', '', {
|
|
1716
|
-
query,
|
|
1902
|
+
query: { delete: '' },
|
|
1717
1903
|
body: xmlBody,
|
|
1718
|
-
headers
|
|
1904
|
+
headers: {
|
|
1905
|
+
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
1906
|
+
[C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1907
|
+
[C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1908
|
+
},
|
|
1719
1909
|
withQuery: true,
|
|
1720
1910
|
});
|
|
1911
|
+
|
|
1721
1912
|
const parsed = parseXml(await res.text()) as Record<string, unknown>;
|
|
1722
1913
|
if (!parsed || typeof parsed !== 'object') {
|
|
1723
1914
|
throw new Error(`${C.ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
|
|
1724
1915
|
}
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
}
|
|
1916
|
+
return (parsed.DeleteResult || parsed.deleteResult || parsed) as Record<string, unknown>;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
private _markDeletedKeys(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
|
|
1730
1920
|
const deleted = out.deleted || out.Deleted;
|
|
1731
|
-
if (deleted) {
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1921
|
+
if (!deleted) {
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
const items = Array.isArray(deleted) ? deleted : [deleted];
|
|
1926
|
+
for (const item of items) {
|
|
1927
|
+
if (item && typeof item === 'object') {
|
|
1928
|
+
const key = (item as Record<string, unknown>).key || (item as Record<string, unknown>).Key;
|
|
1929
|
+
if (key && typeof key === 'string') {
|
|
1930
|
+
resultMap.set(key, true);
|
|
1741
1931
|
}
|
|
1742
1932
|
}
|
|
1743
1933
|
}
|
|
1934
|
+
}
|
|
1744
1935
|
|
|
1745
|
-
|
|
1936
|
+
private _logDeleteErrors(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
|
|
1746
1937
|
const errors = out.error || out.Error;
|
|
1747
|
-
if (errors) {
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
message: message || 'Unknown error',
|
|
1763
|
-
});
|
|
1764
|
-
}
|
|
1938
|
+
if (!errors) {
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
const items = Array.isArray(errors) ? errors : [errors];
|
|
1943
|
+
for (const item of items) {
|
|
1944
|
+
if (item && typeof item === 'object') {
|
|
1945
|
+
const obj = item as Record<string, unknown>;
|
|
1946
|
+
const key = obj.key || obj.Key;
|
|
1947
|
+
if (key && typeof key === 'string') {
|
|
1948
|
+
resultMap.set(key, false);
|
|
1949
|
+
this._log('warn', `Failed to delete object: ${key}`, {
|
|
1950
|
+
code: obj.code || obj.Code || 'Unknown',
|
|
1951
|
+
message: obj.message || obj.Message || 'Unknown error',
|
|
1952
|
+
});
|
|
1765
1953
|
}
|
|
1766
1954
|
}
|
|
1767
1955
|
}
|
|
1768
|
-
|
|
1769
|
-
// Return boolean array in the same order as input keys
|
|
1770
|
-
return keys.map(key => resultMap.get(key) || false);
|
|
1771
1956
|
}
|
|
1772
1957
|
|
|
1773
1958
|
/**
|
|
@@ -1862,8 +2047,9 @@ class S3mini {
|
|
|
1862
2047
|
return '';
|
|
1863
2048
|
}
|
|
1864
2049
|
return Object.keys(queryParams)
|
|
1865
|
-
.map(key =>
|
|
1866
|
-
.sort((a, b) => a
|
|
2050
|
+
.map((key): [string, string] => [encodeURIComponent(key), encodeURIComponent(String(queryParams[key]))])
|
|
2051
|
+
.sort(([a], [b]) => byCodePoint(a, b))
|
|
2052
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
1867
2053
|
.join('&');
|
|
1868
2054
|
}
|
|
1869
2055
|
/**
|
|
@@ -1902,6 +2088,9 @@ class S3mini {
|
|
|
1902
2088
|
if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
|
|
1903
2089
|
throw new TypeError(`${C.ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
|
|
1904
2090
|
}
|
|
2091
|
+
if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
|
|
2092
|
+
return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
|
|
2093
|
+
}
|
|
1905
2094
|
return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
|
|
1906
2095
|
}
|
|
1907
2096
|
|
|
@@ -1933,7 +2122,7 @@ class S3mini {
|
|
|
1933
2122
|
headerEntries.push([lowerKey, String(value).trim()]);
|
|
1934
2123
|
}
|
|
1935
2124
|
}
|
|
1936
|
-
headerEntries.sort(([a], [b]) => a
|
|
2125
|
+
headerEntries.sort(([a], [b]) => byCodePoint(a, b));
|
|
1937
2126
|
|
|
1938
2127
|
const canonicalHeaders = headerEntries.map(([k, v]) => `${k}:${v}`).join('\n');
|
|
1939
2128
|
const signedHeaders = headerEntries.map(([k]) => k).join(';');
|
package/src/types.ts
CHANGED
|
@@ -163,3 +163,57 @@ type MaybeBuffer = typeof globalThis extends { Buffer?: infer B }
|
|
|
163
163
|
: BinaryData;
|
|
164
164
|
|
|
165
165
|
export type DataInput = string | MaybeBuffer | ReadableStream | File | Blob;
|
|
166
|
+
|
|
167
|
+
// Bun-native S3 interfaces (zero-cost in non-Bun runtimes)
|
|
168
|
+
export interface NativeS3Stat {
|
|
169
|
+
size: number;
|
|
170
|
+
etag: string;
|
|
171
|
+
lastModified: Date;
|
|
172
|
+
type: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface NativeS3File {
|
|
176
|
+
text(): Promise<string>;
|
|
177
|
+
json(): Promise<unknown>;
|
|
178
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
179
|
+
bytes(): Promise<Uint8Array>;
|
|
180
|
+
stream(): ReadableStream;
|
|
181
|
+
slice(start?: number, end?: number): NativeS3File;
|
|
182
|
+
write(data: string | ArrayBuffer | Uint8Array | Blob | ReadableStream, opts?: { type?: string }): Promise<number>;
|
|
183
|
+
writer(opts?: { type?: string }): { write(data: unknown): void; flush(): Promise<void>; end(): Promise<void> };
|
|
184
|
+
delete(): Promise<void>;
|
|
185
|
+
unlink(): Promise<void>;
|
|
186
|
+
exists(): Promise<boolean>;
|
|
187
|
+
stat(): Promise<NativeS3Stat>;
|
|
188
|
+
presign(opts?: { method?: string; expiresIn?: number; acl?: string; type?: string }): string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface NativeS3ListObject {
|
|
192
|
+
key: string;
|
|
193
|
+
lastModified: Date;
|
|
194
|
+
size: number;
|
|
195
|
+
etag: string;
|
|
196
|
+
storageClass?: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface NativeS3ListResult {
|
|
200
|
+
contents?: NativeS3ListObject[];
|
|
201
|
+
commonPrefixes?: { prefix: string }[];
|
|
202
|
+
isTruncated: boolean;
|
|
203
|
+
nextContinuationToken?: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface NativeS3Client {
|
|
207
|
+
file(key: string): NativeS3File;
|
|
208
|
+
write(
|
|
209
|
+
key: string,
|
|
210
|
+
data: string | ArrayBuffer | Uint8Array | Blob | ReadableStream,
|
|
211
|
+
opts?: Record<string, unknown>,
|
|
212
|
+
): Promise<number>;
|
|
213
|
+
delete(key: string): Promise<void>;
|
|
214
|
+
exists(key: string): Promise<boolean>;
|
|
215
|
+
size(key: string): Promise<number>;
|
|
216
|
+
stat(key: string): Promise<NativeS3Stat>;
|
|
217
|
+
presign(key: string, opts?: { method?: string; expiresIn?: number; acl?: string; type?: string }): string;
|
|
218
|
+
list(opts?: Record<string, unknown> | null, credentials?: Record<string, unknown>): Promise<NativeS3ListResult>;
|
|
219
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
import type { DataInput, XmlValue, XmlMap, ListBucketResponse, ErrorWithCode, PartData } from './types.js';
|
|
4
4
|
import { ERROR_PREFIX } from './consts.js';
|
|
5
5
|
|
|
6
|
+
export const isBun = typeof navigator !== 'undefined' && navigator.userAgent === 'Bun';
|
|
7
|
+
|
|
8
|
+
/** Strips the bucket name from a full endpoint URL, returning the base origin for Bun.S3Client. */
|
|
9
|
+
export const extractBaseEndpoint = (endpoint: URL, bucket: string): string => {
|
|
10
|
+
// Path-style (/bucket/…): just use the origin
|
|
11
|
+
if (endpoint.pathname.split('/').some(Boolean)) {
|
|
12
|
+
return endpoint.origin;
|
|
13
|
+
}
|
|
14
|
+
// Virtual-hosted (bucket.host…): strip the bucket subdomain
|
|
15
|
+
const prefix = bucket + '.';
|
|
16
|
+
if (endpoint.hostname.startsWith(prefix)) {
|
|
17
|
+
const base = endpoint.hostname.slice(prefix.length);
|
|
18
|
+
return `${endpoint.protocol}//${base}${endpoint.port ? ':' + endpoint.port : ''}`;
|
|
19
|
+
}
|
|
20
|
+
return endpoint.origin;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compare two strings by code point, as required for AWS SigV4 canonical
|
|
25
|
+
* ordering of query parameters and headers. `localeCompare` MUST NOT be used
|
|
26
|
+
* here: it is locale-aware and case-insensitive by default, so it mis-orders
|
|
27
|
+
* mixed-case names (e.g. `partNumber` before `X-Amz-*`) and breaks signatures.
|
|
28
|
+
* @param a First string
|
|
29
|
+
* @param b Second string
|
|
30
|
+
* @returns -1, 0, or 1
|
|
31
|
+
*/
|
|
32
|
+
export const byCodePoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
33
|
+
|
|
6
34
|
const ENCODR = new TextEncoder();
|
|
7
35
|
const chunkSize = 0x8000; // 32KB chunks
|
|
8
36
|
const HEX_CHARS = new Uint8Array([48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102]);
|