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/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;
|
|
@@ -548,15 +575,20 @@ class S3mini {
|
|
|
548
575
|
|
|
549
576
|
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
550
577
|
let token: string | undefined = nextContinuationToken;
|
|
578
|
+
let remaining = maxKeys;
|
|
551
579
|
const all: IT.ListObject[] = [];
|
|
552
580
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
581
|
+
do {
|
|
582
|
+
const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
|
|
583
|
+
if (batchResult === null) {
|
|
584
|
+
return null; // 404 - bucket not found
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
all.push(...batchResult.objects);
|
|
588
|
+
remaining -= batchResult.objects.length;
|
|
589
|
+
token = batchResult.continuationToken;
|
|
590
|
+
} while (token && remaining > 0);
|
|
557
591
|
|
|
558
|
-
all.push(...batchResult.objects);
|
|
559
|
-
token = batchResult.continuationToken;
|
|
560
592
|
return { objects: all, nextContinuationToken: token };
|
|
561
593
|
}
|
|
562
594
|
|
|
@@ -693,6 +725,90 @@ class S3mini {
|
|
|
693
725
|
response.nextMarker) as string | undefined;
|
|
694
726
|
}
|
|
695
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
|
+
|
|
696
812
|
/**
|
|
697
813
|
* Lists multipart uploads in the bucket.
|
|
698
814
|
* This method sends a request to list multipart uploads in the specified bucket.
|
|
@@ -751,7 +867,9 @@ class S3mini {
|
|
|
751
867
|
opts: Record<string, unknown> = {},
|
|
752
868
|
ssecHeaders?: IT.SSECHeaders,
|
|
753
869
|
): Promise<string | null> {
|
|
754
|
-
|
|
870
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
871
|
+
return this._bunRead(key, f => f.text());
|
|
872
|
+
}
|
|
755
873
|
const res = await this._signedRequest('GET', key, {
|
|
756
874
|
query: opts, // use opts.query if it exists, otherwise use an empty object
|
|
757
875
|
tolerated: [200, 404, 412, 304],
|
|
@@ -803,6 +921,9 @@ class S3mini {
|
|
|
803
921
|
opts: Record<string, unknown> = {},
|
|
804
922
|
ssecHeaders?: IT.SSECHeaders,
|
|
805
923
|
): Promise<ArrayBuffer | null> {
|
|
924
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
925
|
+
return this._bunRead(key, f => f.arrayBuffer());
|
|
926
|
+
}
|
|
806
927
|
const res = await this._signedRequest('GET', key, {
|
|
807
928
|
query: opts,
|
|
808
929
|
tolerated: [200, 404, 412, 304],
|
|
@@ -828,6 +949,9 @@ class S3mini {
|
|
|
828
949
|
opts: Record<string, unknown> = {},
|
|
829
950
|
ssecHeaders?: IT.SSECHeaders,
|
|
830
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
|
+
}
|
|
831
955
|
const res = await this._signedRequest('GET', key, {
|
|
832
956
|
query: opts,
|
|
833
957
|
tolerated: [200, 404, 412, 304],
|
|
@@ -853,6 +977,18 @@ class S3mini {
|
|
|
853
977
|
opts: Record<string, unknown> = {},
|
|
854
978
|
ssecHeaders?: IT.SSECHeaders,
|
|
855
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
|
+
}
|
|
856
992
|
try {
|
|
857
993
|
const res = await this._signedRequest('GET', key, {
|
|
858
994
|
query: opts,
|
|
@@ -895,6 +1031,29 @@ class S3mini {
|
|
|
895
1031
|
opts: Record<string, unknown> = {},
|
|
896
1032
|
ssecHeaders?: IT.SSECHeaders,
|
|
897
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
|
+
|
|
898
1057
|
let rangeHdr: Record<string, string | number> = {};
|
|
899
1058
|
|
|
900
1059
|
if (!wholeFile) {
|
|
@@ -917,6 +1076,9 @@ class S3mini {
|
|
|
917
1076
|
*/
|
|
918
1077
|
public async getContentLength(key: string, ssecHeaders?: IT.SSECHeaders): Promise<number> {
|
|
919
1078
|
try {
|
|
1079
|
+
if (this._bun && !ssecHeaders) {
|
|
1080
|
+
return (await this._bun.file(key).stat()).size;
|
|
1081
|
+
}
|
|
920
1082
|
const res = await this._signedRequest('HEAD', key, {
|
|
921
1083
|
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
922
1084
|
});
|
|
@@ -938,6 +1100,9 @@ class S3mini {
|
|
|
938
1100
|
* @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
|
|
939
1101
|
*/
|
|
940
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
|
+
}
|
|
941
1106
|
const res = await this._signedRequest('HEAD', key, {
|
|
942
1107
|
query: opts,
|
|
943
1108
|
tolerated: [200, 404, 412, 304],
|
|
@@ -970,6 +1135,15 @@ class S3mini {
|
|
|
970
1135
|
opts: Record<string, unknown> = {},
|
|
971
1136
|
ssecHeaders?: IT.SSECHeaders,
|
|
972
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
|
+
}
|
|
973
1147
|
const res = await this._signedRequest('HEAD', key, {
|
|
974
1148
|
query: opts,
|
|
975
1149
|
tolerated: [200, 304, 404, 412],
|
|
@@ -1017,6 +1191,12 @@ class S3mini {
|
|
|
1017
1191
|
additionalHeaders?: IT.AWSHeaders,
|
|
1018
1192
|
contentLength?: number,
|
|
1019
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
|
+
}
|
|
1020
1200
|
const size = contentLength ?? getByteSize(data);
|
|
1021
1201
|
return this._signedRequest('PUT', key, {
|
|
1022
1202
|
body: data as BodyInit,
|
|
@@ -1057,6 +1237,15 @@ class S3mini {
|
|
|
1057
1237
|
additionalHeaders?: IT.AWSHeaders,
|
|
1058
1238
|
contentLength?: number,
|
|
1059
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
|
+
|
|
1060
1249
|
const size = contentLength ?? getByteSize(data);
|
|
1061
1250
|
|
|
1062
1251
|
// Single PUT for small files
|
|
@@ -1692,77 +1881,84 @@ class S3mini {
|
|
|
1692
1881
|
* @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
|
|
1693
1882
|
*/
|
|
1694
1883
|
public async deleteObject(key: string): Promise<boolean> {
|
|
1884
|
+
if (this._bun) {
|
|
1885
|
+
await this._bun.file(key).delete();
|
|
1886
|
+
return true;
|
|
1887
|
+
}
|
|
1695
1888
|
const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
|
|
1696
1889
|
return res.status === 200 || res.status === 204;
|
|
1697
1890
|
}
|
|
1698
1891
|
|
|
1699
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>> {
|
|
1700
1903
|
const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
|
|
1701
1904
|
const xmlBody = '<Delete>' + objectsXml + '</Delete>';
|
|
1702
|
-
const query = { delete: '' };
|
|
1703
1905
|
const sha256base64 = base64FromBuffer(await sha256(xmlBody));
|
|
1704
|
-
const headers = {
|
|
1705
|
-
[C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
|
|
1706
|
-
[C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1707
|
-
[C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1708
|
-
};
|
|
1709
1906
|
|
|
1710
1907
|
const res = await this._signedRequest('POST', '', {
|
|
1711
|
-
query,
|
|
1908
|
+
query: { delete: '' },
|
|
1712
1909
|
body: xmlBody,
|
|
1713
|
-
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
|
+
},
|
|
1714
1915
|
withQuery: true,
|
|
1715
1916
|
});
|
|
1917
|
+
|
|
1716
1918
|
const parsed = parseXml(await res.text()) as Record<string, unknown>;
|
|
1717
1919
|
if (!parsed || typeof parsed !== 'object') {
|
|
1718
1920
|
throw new Error(`${C.ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
|
|
1719
1921
|
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
}
|
|
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 {
|
|
1725
1926
|
const deleted = out.deleted || out.Deleted;
|
|
1726
|
-
if (deleted) {
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
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);
|
|
1736
1937
|
}
|
|
1737
1938
|
}
|
|
1738
1939
|
}
|
|
1940
|
+
}
|
|
1739
1941
|
|
|
1740
|
-
|
|
1942
|
+
private _logDeleteErrors(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
|
|
1741
1943
|
const errors = out.error || out.Error;
|
|
1742
|
-
if (errors) {
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
message: message || 'Unknown error',
|
|
1758
|
-
});
|
|
1759
|
-
}
|
|
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
|
+
});
|
|
1760
1959
|
}
|
|
1761
1960
|
}
|
|
1762
1961
|
}
|
|
1763
|
-
|
|
1764
|
-
// Return boolean array in the same order as input keys
|
|
1765
|
-
return keys.map(key => resultMap.get(key) || false);
|
|
1766
1962
|
}
|
|
1767
1963
|
|
|
1768
1964
|
/**
|
|
@@ -1869,30 +2065,38 @@ class S3mini {
|
|
|
1869
2065
|
* @param {'GET' | 'PUT'} method - HTTP method ('GET' for download, 'PUT' for upload)
|
|
1870
2066
|
* @param {string} key - The object key/path
|
|
1871
2067
|
* @param {number} [expiresIn=3600] - URL expiration time in seconds (1–604800)
|
|
1872
|
-
* @param {Record<string, string>} [queryParams={}] - Additional query parameters to
|
|
2068
|
+
* @param {Record<string, string>} [queryParams={}] - Additional query parameters to include in the URL
|
|
2069
|
+
* @param {Record<string, string>} [headers={}] - HTTP headers to sign. The consumer of the URL
|
|
2070
|
+
* MUST send these exact headers with matching values. The `host` header is always signed automatically.
|
|
1873
2071
|
* @returns {Promise<string>} Pre-signed URL string
|
|
1874
2072
|
* @throws {TypeError} If key is empty or expiresIn is out of range
|
|
1875
2073
|
* @example
|
|
1876
2074
|
* // Download URL valid for 1 hour
|
|
1877
2075
|
* const url = await s3.getPresignedUrl('GET', 'photos/vacation.jpg');
|
|
1878
2076
|
*
|
|
1879
|
-
* // Upload URL valid for 5 minutes
|
|
1880
|
-
* const url = await s3.getPresignedUrl('PUT', 'uploads/file.bin', 300
|
|
2077
|
+
* // Upload URL valid for 5 minutes with signed Content-Type
|
|
2078
|
+
* const url = await s3.getPresignedUrl('PUT', 'uploads/file.bin', 300, {}, {
|
|
2079
|
+
* 'Content-Type': 'application/octet-stream',
|
|
2080
|
+
* });
|
|
1881
2081
|
*
|
|
1882
|
-
* // Client-side usage (
|
|
1883
|
-
* await fetch(url, { method: 'PUT', body: data });
|
|
2082
|
+
* // Client-side usage (must include signed headers)
|
|
2083
|
+
* await fetch(url, { method: 'PUT', body: data, headers: { 'Content-Type': 'application/octet-stream' } });
|
|
1884
2084
|
*/
|
|
1885
2085
|
public async getPresignedUrl(
|
|
1886
2086
|
method: 'GET' | 'PUT',
|
|
1887
2087
|
key: string,
|
|
1888
2088
|
expiresIn: number = 3600,
|
|
1889
2089
|
queryParams: Record<string, string> = {},
|
|
2090
|
+
headers: Record<string, string> = {},
|
|
1890
2091
|
): Promise<string> {
|
|
1891
2092
|
this._checkKey(key);
|
|
1892
2093
|
if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
|
|
1893
2094
|
throw new TypeError(`${C.ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
|
|
1894
2095
|
}
|
|
1895
|
-
|
|
2096
|
+
if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
|
|
2097
|
+
return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
|
|
2098
|
+
}
|
|
2099
|
+
return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
|
|
1896
2100
|
}
|
|
1897
2101
|
|
|
1898
2102
|
private async _presign(
|
|
@@ -1900,6 +2104,7 @@ class S3mini {
|
|
|
1900
2104
|
keyPath: string,
|
|
1901
2105
|
expiresIn: number,
|
|
1902
2106
|
queryParams: Record<string, string>,
|
|
2107
|
+
headers: Record<string, string>,
|
|
1903
2108
|
): Promise<string> {
|
|
1904
2109
|
const url = new URL(this.endpoint);
|
|
1905
2110
|
if (keyPath.length > 0) {
|
|
@@ -1915,7 +2120,17 @@ class S3mini {
|
|
|
1915
2120
|
const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
|
|
1916
2121
|
const credentialScope = `${shortDatetime}/${this.region}/${C.S3_SERVICE}/${C.AWS_REQUEST_TYPE}`;
|
|
1917
2122
|
|
|
1918
|
-
const
|
|
2123
|
+
const headerEntries: Array<[string, string]> = [['host', url.host]];
|
|
2124
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
2125
|
+
const lowerKey = key.toLowerCase();
|
|
2126
|
+
if (lowerKey !== 'host') {
|
|
2127
|
+
headerEntries.push([lowerKey, String(value).trim()]);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
headerEntries.sort(([a], [b]) => a.localeCompare(b));
|
|
2131
|
+
|
|
2132
|
+
const canonicalHeaders = headerEntries.map(([k, v]) => `${k}:${v}`).join('\n');
|
|
2133
|
+
const signedHeaders = headerEntries.map(([k]) => k).join(';');
|
|
1919
2134
|
|
|
1920
2135
|
const allQueryParams: Record<string, string> = {
|
|
1921
2136
|
...queryParams,
|
|
@@ -1927,7 +2142,7 @@ class S3mini {
|
|
|
1927
2142
|
};
|
|
1928
2143
|
|
|
1929
2144
|
const canonicalQueryString = this._buildCanonicalQueryString(allQueryParams);
|
|
1930
|
-
const canonicalRequest = `${method}\n${url.pathname}\n${canonicalQueryString}\
|
|
2145
|
+
const canonicalRequest = `${method}\n${url.pathname}\n${canonicalQueryString}\n${canonicalHeaders}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
|
|
1931
2146
|
const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
|
|
1932
2147
|
|
|
1933
2148
|
if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
|
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]);
|
|
@@ -149,7 +166,7 @@ const unescapeXml = (value: string): string =>
|
|
|
149
166
|
|
|
150
167
|
export const parseXml = (input: string): XmlValue => {
|
|
151
168
|
const xmlContent = input.replace(/<\?xml[^?]*\?>\s*/, '');
|
|
152
|
-
const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]
|
|
169
|
+
const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]*?>([\s\S]*?)<\/\1>/gm;
|
|
153
170
|
const result: XmlMap = {}; // strong type, no `any`
|
|
154
171
|
let match: RegExpExecArray | null;
|
|
155
172
|
|