s3mini 0.9.3 → 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 +9 -1
- package/dist/s3mini.d.ts +10 -0
- package/dist/s3mini.js +246 -68
- 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 +264 -70
- package/src/types.ts +54 -0
- package/src/utils.ts +17 -0
package/src/S3.ts
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
S3ServiceError,
|
|
18
18
|
generateParts,
|
|
19
19
|
toUint8Array,
|
|
20
|
+
isBun,
|
|
21
|
+
extractBaseEndpoint,
|
|
20
22
|
} from './utils.js';
|
|
21
23
|
import type * as IT from './types.js';
|
|
22
24
|
|
|
@@ -70,6 +72,7 @@ class S3mini {
|
|
|
70
72
|
readonly logger?: IT.Logger;
|
|
71
73
|
readonly _fetch: typeof fetch;
|
|
72
74
|
readonly minPartSize: number;
|
|
75
|
+
private readonly _bun?: IT.NativeS3Client;
|
|
73
76
|
private signingKeyDate?: string;
|
|
74
77
|
private signingKey?: ArrayBuffer;
|
|
75
78
|
|
|
@@ -95,6 +98,19 @@ class S3mini {
|
|
|
95
98
|
this.logger = logger;
|
|
96
99
|
this._fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => fetch(input, init);
|
|
97
100
|
this.minPartSize = minPartSize;
|
|
101
|
+
|
|
102
|
+
if (isBun) {
|
|
103
|
+
const { S3Client } = (
|
|
104
|
+
globalThis as unknown as { Bun: { S3Client: new (o: Record<string, unknown>) => IT.NativeS3Client } }
|
|
105
|
+
).Bun;
|
|
106
|
+
this._bun = new S3Client({
|
|
107
|
+
accessKeyId,
|
|
108
|
+
secretAccessKey,
|
|
109
|
+
endpoint: extractBaseEndpoint(this.endpoint, this.bucketName),
|
|
110
|
+
region: this.region,
|
|
111
|
+
bucket: this.bucketName,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
98
114
|
}
|
|
99
115
|
|
|
100
116
|
private _sanitize(obj: unknown): unknown {
|
|
@@ -169,6 +185,18 @@ class S3mini {
|
|
|
169
185
|
return this.#accessKeyId.trim().length > 0 && this.#secretAccessKey.trim().length > 0;
|
|
170
186
|
}
|
|
171
187
|
|
|
188
|
+
/** Run a read op via Bun-native S3, returning null on NoSuchKey. */
|
|
189
|
+
private async _bunRead<T>(key: string, op: (f: IT.NativeS3File) => Promise<T>): Promise<T | null> {
|
|
190
|
+
try {
|
|
191
|
+
return await op(this._bun!.file(key));
|
|
192
|
+
} catch (e) {
|
|
193
|
+
if ((e as { code?: string })?.code === 'NoSuchKey') {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
throw e;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
172
200
|
private _ensureValidUrl(raw: string): string {
|
|
173
201
|
const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
174
202
|
try {
|
|
@@ -426,37 +454,29 @@ class S3mini {
|
|
|
426
454
|
private _extractBucketName(): string {
|
|
427
455
|
const url = this.endpoint;
|
|
428
456
|
|
|
429
|
-
//
|
|
430
|
-
const
|
|
431
|
-
if (
|
|
432
|
-
|
|
433
|
-
return pathSegments[0];
|
|
434
|
-
}
|
|
457
|
+
// Path-style: bucket is the first non-empty path segment
|
|
458
|
+
const firstSegment = url.pathname.split('/').find(Boolean);
|
|
459
|
+
if (firstSegment) {
|
|
460
|
+
return firstSegment;
|
|
435
461
|
}
|
|
436
462
|
|
|
437
|
-
//
|
|
438
|
-
const
|
|
463
|
+
// Virtual-hosted style: bucket is the first subdomain label
|
|
464
|
+
const hostname = url.hostname;
|
|
439
465
|
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// bucket-name.region.cdn.digitaloceanspaces.com
|
|
466
|
+
// IP addresses (v4: digits+dots, v6: contains colons) can't carry a bucket subdomain
|
|
467
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
|
|
468
|
+
return '';
|
|
469
|
+
}
|
|
445
470
|
|
|
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'];
|
|
471
|
+
const labels = hostname.split('.');
|
|
450
472
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
473
|
+
// Need ≥3 labels for virtual-hosted (bucket.service.tld)
|
|
474
|
+
// Single-label (localhost) or two-label (example.com) have no room for a bucket subdomain
|
|
475
|
+
if (labels.length < 3) {
|
|
476
|
+
return '';
|
|
456
477
|
}
|
|
457
478
|
|
|
458
|
-
|
|
459
|
-
return hostParts[0] || '';
|
|
479
|
+
return labels[0]!;
|
|
460
480
|
}
|
|
461
481
|
|
|
462
482
|
/**
|
|
@@ -494,6 +514,13 @@ class S3mini {
|
|
|
494
514
|
this._checkPrefix(prefix);
|
|
495
515
|
this._checkOpts(opts);
|
|
496
516
|
|
|
517
|
+
if (this._bun && delimiter === '/') {
|
|
518
|
+
const extraKeys = Object.keys(opts).filter(k => k !== 'delimiter');
|
|
519
|
+
if (extraKeys.length === 0) {
|
|
520
|
+
return this._bunListAll(prefix, maxKeys, opts.delimiter as string | undefined);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
497
524
|
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
498
525
|
const unlimited = !(maxKeys && maxKeys > 0);
|
|
499
526
|
let remaining = unlimited ? Infinity : maxKeys;
|
|
@@ -698,6 +725,90 @@ class S3mini {
|
|
|
698
725
|
response.nextMarker) as string | undefined;
|
|
699
726
|
}
|
|
700
727
|
|
|
728
|
+
private async _bunListAll(
|
|
729
|
+
prefix: string,
|
|
730
|
+
maxKeys: number | undefined,
|
|
731
|
+
delimiter: string | undefined,
|
|
732
|
+
): Promise<IT.ListObject[] | null> {
|
|
733
|
+
const unlimited = !(maxKeys && maxKeys > 0);
|
|
734
|
+
let remaining = unlimited ? Infinity : maxKeys;
|
|
735
|
+
let startAfter: string | undefined;
|
|
736
|
+
const all: IT.ListObject[] = [];
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
do {
|
|
740
|
+
const batchSize = Math.min(remaining === Infinity ? 1000 : remaining, 1000);
|
|
741
|
+
const res = await this._bunFetchPage(prefix, delimiter, batchSize, startAfter);
|
|
742
|
+
const mapped = this._bunMapListResult(res);
|
|
743
|
+
all.push(...mapped);
|
|
744
|
+
|
|
745
|
+
if (!unlimited) {
|
|
746
|
+
remaining -= mapped.length;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
startAfter = this._bunNextCursor(res);
|
|
750
|
+
} while (startAfter && remaining > 0);
|
|
751
|
+
} catch (e) {
|
|
752
|
+
if ((e as { code?: string })?.code === 'NoSuchBucket') {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
throw e;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
return all;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private _bunFetchPage(
|
|
762
|
+
prefix: string,
|
|
763
|
+
delimiter: string | undefined,
|
|
764
|
+
maxKeys: number,
|
|
765
|
+
startAfter?: string,
|
|
766
|
+
): Promise<IT.NativeS3ListResult> {
|
|
767
|
+
return this._bun!.list({
|
|
768
|
+
prefix: prefix || undefined,
|
|
769
|
+
delimiter: delimiter || '/',
|
|
770
|
+
maxKeys,
|
|
771
|
+
...(startAfter ? { startAfter } : {}),
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
private _bunNextCursor(res: IT.NativeS3ListResult): string | undefined {
|
|
776
|
+
if (!res.isTruncated) {
|
|
777
|
+
return undefined;
|
|
778
|
+
}
|
|
779
|
+
return res.contents?.length ? res.contents.at(-1)!.key : undefined;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private _bunMapListResult(res: IT.NativeS3ListResult): IT.ListObject[] {
|
|
783
|
+
const objects: IT.ListObject[] = [];
|
|
784
|
+
|
|
785
|
+
if (res.contents) {
|
|
786
|
+
for (const item of res.contents) {
|
|
787
|
+
objects.push({
|
|
788
|
+
Key: item.key,
|
|
789
|
+
Size: item.size,
|
|
790
|
+
LastModified: item.lastModified instanceof Date ? item.lastModified : new Date(item.lastModified),
|
|
791
|
+
ETag: item.etag ?? '',
|
|
792
|
+
StorageClass: item.storageClass ?? '',
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (res.commonPrefixes) {
|
|
798
|
+
for (const item of res.commonPrefixes) {
|
|
799
|
+
objects.push({
|
|
800
|
+
Key: item.prefix,
|
|
801
|
+
Size: 0,
|
|
802
|
+
LastModified: new Date(0),
|
|
803
|
+
ETag: '',
|
|
804
|
+
StorageClass: '',
|
|
805
|
+
} as IT.ListObject);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return objects;
|
|
810
|
+
}
|
|
811
|
+
|
|
701
812
|
/**
|
|
702
813
|
* Lists multipart uploads in the bucket.
|
|
703
814
|
* This method sends a request to list multipart uploads in the specified bucket.
|
|
@@ -756,7 +867,9 @@ class S3mini {
|
|
|
756
867
|
opts: Record<string, unknown> = {},
|
|
757
868
|
ssecHeaders?: IT.SSECHeaders,
|
|
758
869
|
): Promise<string | null> {
|
|
759
|
-
|
|
870
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
871
|
+
return this._bunRead(key, f => f.text());
|
|
872
|
+
}
|
|
760
873
|
const res = await this._signedRequest('GET', key, {
|
|
761
874
|
query: opts, // use opts.query if it exists, otherwise use an empty object
|
|
762
875
|
tolerated: [200, 404, 412, 304],
|
|
@@ -808,6 +921,9 @@ class S3mini {
|
|
|
808
921
|
opts: Record<string, unknown> = {},
|
|
809
922
|
ssecHeaders?: IT.SSECHeaders,
|
|
810
923
|
): Promise<ArrayBuffer | null> {
|
|
924
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
925
|
+
return this._bunRead(key, f => f.arrayBuffer());
|
|
926
|
+
}
|
|
811
927
|
const res = await this._signedRequest('GET', key, {
|
|
812
928
|
query: opts,
|
|
813
929
|
tolerated: [200, 404, 412, 304],
|
|
@@ -833,6 +949,9 @@ class S3mini {
|
|
|
833
949
|
opts: Record<string, unknown> = {},
|
|
834
950
|
ssecHeaders?: IT.SSECHeaders,
|
|
835
951
|
): Promise<T | null> {
|
|
952
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
953
|
+
return this._bunRead(key, f => f.json()) as Promise<T | null>;
|
|
954
|
+
}
|
|
836
955
|
const res = await this._signedRequest('GET', key, {
|
|
837
956
|
query: opts,
|
|
838
957
|
tolerated: [200, 404, 412, 304],
|
|
@@ -858,6 +977,18 @@ class S3mini {
|
|
|
858
977
|
opts: Record<string, unknown> = {},
|
|
859
978
|
ssecHeaders?: IT.SSECHeaders,
|
|
860
979
|
): Promise<{ etag: string | null; data: ArrayBuffer | null }> {
|
|
980
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
981
|
+
try {
|
|
982
|
+
const f = this._bun.file(key);
|
|
983
|
+
const [stat, data] = await Promise.all([f.stat(), f.arrayBuffer()]);
|
|
984
|
+
return { etag: sanitizeETag(stat.etag), data };
|
|
985
|
+
} catch (e) {
|
|
986
|
+
if ((e as { code?: string })?.code === 'NoSuchKey') {
|
|
987
|
+
return { etag: null, data: null };
|
|
988
|
+
}
|
|
989
|
+
throw e;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
861
992
|
try {
|
|
862
993
|
const res = await this._signedRequest('GET', key, {
|
|
863
994
|
query: opts,
|
|
@@ -900,6 +1031,29 @@ class S3mini {
|
|
|
900
1031
|
opts: Record<string, unknown> = {},
|
|
901
1032
|
ssecHeaders?: IT.SSECHeaders,
|
|
902
1033
|
): Promise<Response> {
|
|
1034
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1035
|
+
const f = this._bun.file(key);
|
|
1036
|
+
if (wholeFile) {
|
|
1037
|
+
const buf = await f.arrayBuffer();
|
|
1038
|
+
const stat = await f.stat();
|
|
1039
|
+
return new Response(buf, {
|
|
1040
|
+
status: 200,
|
|
1041
|
+
headers: { 'content-length': String(stat.size), etag: stat.etag, 'content-type': stat.type },
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
const sliced = rangeTo === undefined ? f.slice(rangeFrom) : f.slice(rangeFrom, rangeTo);
|
|
1045
|
+
const buf = await sliced.arrayBuffer();
|
|
1046
|
+
const stat = await f.stat();
|
|
1047
|
+
const endByte = rangeTo === undefined ? stat.size - 1 : rangeTo - 1;
|
|
1048
|
+
return new Response(buf, {
|
|
1049
|
+
status: 206,
|
|
1050
|
+
headers: {
|
|
1051
|
+
'content-range': `bytes ${rangeFrom}-${endByte}/${stat.size}`,
|
|
1052
|
+
'content-length': String(buf.byteLength),
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
903
1057
|
let rangeHdr: Record<string, string | number> = {};
|
|
904
1058
|
|
|
905
1059
|
if (!wholeFile) {
|
|
@@ -922,6 +1076,9 @@ class S3mini {
|
|
|
922
1076
|
*/
|
|
923
1077
|
public async getContentLength(key: string, ssecHeaders?: IT.SSECHeaders): Promise<number> {
|
|
924
1078
|
try {
|
|
1079
|
+
if (this._bun && !ssecHeaders) {
|
|
1080
|
+
return (await this._bun.file(key).stat()).size;
|
|
1081
|
+
}
|
|
925
1082
|
const res = await this._signedRequest('HEAD', key, {
|
|
926
1083
|
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
927
1084
|
});
|
|
@@ -943,6 +1100,9 @@ class S3mini {
|
|
|
943
1100
|
* @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
|
|
944
1101
|
*/
|
|
945
1102
|
public async objectExists(key: string, opts: Record<string, unknown> = {}): Promise<IT.ExistResponseCode> {
|
|
1103
|
+
if (this._bun && !Object.keys(opts).length) {
|
|
1104
|
+
return this._bun.file(key).exists();
|
|
1105
|
+
}
|
|
946
1106
|
const res = await this._signedRequest('HEAD', key, {
|
|
947
1107
|
query: opts,
|
|
948
1108
|
tolerated: [200, 404, 412, 304],
|
|
@@ -975,6 +1135,15 @@ class S3mini {
|
|
|
975
1135
|
opts: Record<string, unknown> = {},
|
|
976
1136
|
ssecHeaders?: IT.SSECHeaders,
|
|
977
1137
|
): Promise<string | null> {
|
|
1138
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1139
|
+
return this._bunRead(key, async f => {
|
|
1140
|
+
const { etag } = await f.stat();
|
|
1141
|
+
if (!etag) {
|
|
1142
|
+
throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
|
|
1143
|
+
}
|
|
1144
|
+
return sanitizeETag(etag);
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
978
1147
|
const res = await this._signedRequest('HEAD', key, {
|
|
979
1148
|
query: opts,
|
|
980
1149
|
tolerated: [200, 304, 404, 412],
|
|
@@ -1022,6 +1191,12 @@ class S3mini {
|
|
|
1022
1191
|
additionalHeaders?: IT.AWSHeaders,
|
|
1023
1192
|
contentLength?: number,
|
|
1024
1193
|
): Promise<Response> {
|
|
1194
|
+
if (this._bun && !ssecHeaders && !additionalHeaders) {
|
|
1195
|
+
const f = this._bun.file(key);
|
|
1196
|
+
await f.write(data as string | ArrayBuffer | Uint8Array | Blob | ReadableStream, { type: fileType });
|
|
1197
|
+
const { etag } = await f.stat();
|
|
1198
|
+
return new Response(null, { status: 200, headers: etag ? { etag } : {} });
|
|
1199
|
+
}
|
|
1025
1200
|
const size = contentLength ?? getByteSize(data);
|
|
1026
1201
|
return this._signedRequest('PUT', key, {
|
|
1027
1202
|
body: data as BodyInit,
|
|
@@ -1062,6 +1237,15 @@ class S3mini {
|
|
|
1062
1237
|
additionalHeaders?: IT.AWSHeaders,
|
|
1063
1238
|
contentLength?: number,
|
|
1064
1239
|
): Promise<Response | { ok: boolean; status: number; headers: Map<string, string> }> {
|
|
1240
|
+
// Bun handles multipart automatically for large files
|
|
1241
|
+
if (this._bun && !ssecHeaders && !additionalHeaders) {
|
|
1242
|
+
this._checkKey(key);
|
|
1243
|
+
const f = this._bun.file(key);
|
|
1244
|
+
await f.write(data as string | ArrayBuffer | Uint8Array | Blob | ReadableStream, { type: fileType });
|
|
1245
|
+
const { etag } = await f.stat();
|
|
1246
|
+
return this._createSuccessResponse(etag || '');
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1065
1249
|
const size = contentLength ?? getByteSize(data);
|
|
1066
1250
|
|
|
1067
1251
|
// Single PUT for small files
|
|
@@ -1697,77 +1881,84 @@ class S3mini {
|
|
|
1697
1881
|
* @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
|
|
1698
1882
|
*/
|
|
1699
1883
|
public async deleteObject(key: string): Promise<boolean> {
|
|
1884
|
+
if (this._bun) {
|
|
1885
|
+
await this._bun.file(key).delete();
|
|
1886
|
+
return true;
|
|
1887
|
+
}
|
|
1700
1888
|
const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
|
|
1701
1889
|
return res.status === 200 || res.status === 204;
|
|
1702
1890
|
}
|
|
1703
1891
|
|
|
1704
1892
|
private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> {
|
|
1893
|
+
const out = await this._sendDeleteRequest(keys);
|
|
1894
|
+
|
|
1895
|
+
const resultMap = new Map<string, boolean>(keys.map(k => [k, false]));
|
|
1896
|
+
this._markDeletedKeys(out, resultMap);
|
|
1897
|
+
this._logDeleteErrors(out, resultMap);
|
|
1898
|
+
|
|
1899
|
+
return keys.map(key => resultMap.get(key) || false);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
private async _sendDeleteRequest(keys: string[]): Promise<Record<string, unknown>> {
|
|
1705
1903
|
const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
|
|
1706
1904
|
const xmlBody = '<Delete>' + objectsXml + '</Delete>';
|
|
1707
|
-
const query = { delete: '' };
|
|
1708
1905
|
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
1906
|
|
|
1715
1907
|
const res = await this._signedRequest('POST', '', {
|
|
1716
|
-
query,
|
|
1908
|
+
query: { delete: '' },
|
|
1717
1909
|
body: xmlBody,
|
|
1718
|
-
headers
|
|
1910
|
+
headers: {
|
|
1911
|
+
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
1912
|
+
[C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1913
|
+
[C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1914
|
+
},
|
|
1719
1915
|
withQuery: true,
|
|
1720
1916
|
});
|
|
1917
|
+
|
|
1721
1918
|
const parsed = parseXml(await res.text()) as Record<string, unknown>;
|
|
1722
1919
|
if (!parsed || typeof parsed !== 'object') {
|
|
1723
1920
|
throw new Error(`${C.ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
|
|
1724
1921
|
}
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
}
|
|
1922
|
+
return (parsed.DeleteResult || parsed.deleteResult || parsed) as Record<string, unknown>;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
private _markDeletedKeys(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
|
|
1730
1926
|
const deleted = out.deleted || out.Deleted;
|
|
1731
|
-
if (deleted) {
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1927
|
+
if (!deleted) {
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const items = Array.isArray(deleted) ? deleted : [deleted];
|
|
1932
|
+
for (const item of items) {
|
|
1933
|
+
if (item && typeof item === 'object') {
|
|
1934
|
+
const key = (item as Record<string, unknown>).key || (item as Record<string, unknown>).Key;
|
|
1935
|
+
if (key && typeof key === 'string') {
|
|
1936
|
+
resultMap.set(key, true);
|
|
1741
1937
|
}
|
|
1742
1938
|
}
|
|
1743
1939
|
}
|
|
1940
|
+
}
|
|
1744
1941
|
|
|
1745
|
-
|
|
1942
|
+
private _logDeleteErrors(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
|
|
1746
1943
|
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
|
-
}
|
|
1944
|
+
if (!errors) {
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const items = Array.isArray(errors) ? errors : [errors];
|
|
1949
|
+
for (const item of items) {
|
|
1950
|
+
if (item && typeof item === 'object') {
|
|
1951
|
+
const obj = item as Record<string, unknown>;
|
|
1952
|
+
const key = obj.key || obj.Key;
|
|
1953
|
+
if (key && typeof key === 'string') {
|
|
1954
|
+
resultMap.set(key, false);
|
|
1955
|
+
this._log('warn', `Failed to delete object: ${key}`, {
|
|
1956
|
+
code: obj.code || obj.Code || 'Unknown',
|
|
1957
|
+
message: obj.message || obj.Message || 'Unknown error',
|
|
1958
|
+
});
|
|
1765
1959
|
}
|
|
1766
1960
|
}
|
|
1767
1961
|
}
|
|
1768
|
-
|
|
1769
|
-
// Return boolean array in the same order as input keys
|
|
1770
|
-
return keys.map(key => resultMap.get(key) || false);
|
|
1771
1962
|
}
|
|
1772
1963
|
|
|
1773
1964
|
/**
|
|
@@ -1902,6 +2093,9 @@ class S3mini {
|
|
|
1902
2093
|
if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
|
|
1903
2094
|
throw new TypeError(`${C.ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
|
|
1904
2095
|
}
|
|
2096
|
+
if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
|
|
2097
|
+
return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
|
|
2098
|
+
}
|
|
1905
2099
|
return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
|
|
1906
2100
|
}
|
|
1907
2101
|
|
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,23 @@
|
|
|
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
|
+
|
|
6
23
|
const ENCODR = new TextEncoder();
|
|
7
24
|
const chunkSize = 0x8000; // 32KB chunks
|
|
8
25
|
const HEX_CHARS = new Uint8Array([48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102]);
|