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/README.md
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
# s3mini | Tiny & fast S3 client for node and edge platforms.
|
|
1
|
+
# s3mini | Tiny & fast S3 client for node, bun and edge platforms.
|
|
2
2
|
|
|
3
3
|
`s3mini` is an ultra-lightweight Typescript client (~20 KB minified, β15 % more ops/s) for S3-compatible object storage. It runs on Node, Bun, Cloudflare Workers, and other edge platforms. It has been tested on Cloudflare R2, Backblaze B2, DigitalOcean Spaces, Ceph, Oracle, Garage and MinIO. (No Browser support!)
|
|
4
4
|
|
|
5
|
-
[
|
|
6
|
-
[
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
[[codeberg](https://codeberg.org/thinking_tools/s3mini)]
|
|
10
|
+
[[issues](https://codeberg.org/thinking_tools/s3mini/issues)]
|
|
7
11
|
[[npm](https://www.npmjs.com/package/s3mini)]
|
|
8
12
|
|
|
9
13
|
## Features
|
|
10
14
|
|
|
11
|
-
- π Light and fast:
|
|
15
|
+
- π Light and fast: ~20 KB (minified, not gzipped), up to 1.37x faster on Bun vs Node.
|
|
12
16
|
- π§ Zero dependencies; supports AWS SigV4, pre-signed URLs, and SSE-C headers (tested on Cloudflare)
|
|
13
17
|
- π Works on Cloudflare Workers; ideal for edge computing, Node, and Bun (no browser support).
|
|
14
18
|
- π Only the essential S3 APIsβimproved list, put, get, delete, and a few more.
|
|
@@ -25,24 +29,27 @@ Contributions welcome!
|
|
|
25
29
|
|
|
26
30
|
Dev:
|
|
27
31
|
|
|
28
|
-
[
|
|
32
|
+
[](https://codeberg.org/thinking_tools/s3mini/commits/branch/dev)
|
|
33
|
+
[](https://codeberg.org/thinking_tools/s3mini/issues)
|
|
34
|
+
[](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
|
|
35
|
+
[](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
|
|
36
|
+
[](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
|
|
37
|
+
[](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
|
|
38
|
+
[](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
|
|
39
|
+
[](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
|
|
40
|
+
[](https://codeberg.org/thinking_tools/s3mini/actions?workflow=test-e2e.yml)
|
|
41
|
+
|
|
42
|
+
[](https://codeberg.org/thinking_tools/s3mini/stars) **+** [](https://github.com/good-lly/s3mini/stargazers)
|
|
40
43
|
[](https://www.npmjs.com/package/s3mini)
|
|
41
44
|

|
|
42
45
|

|
|
43
|
-
](https://codeberg.org/thinking_tools/s3mini/src/branch/dev/LICENSE)
|
|
47
|
+
|
|
48
|
+
<a href="https://codeberg.org/thinking_tools/s3mini/issues/"> <img src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg" alt="Contributions welcome" /></a>
|
|
49
|
+
|
|
50
|
+
### Bun vs Node
|
|
44
51
|
|
|
45
|
-
|
|
52
|
+
s3mini is tested on both Node and Bun. In our benchmarks against MinIO, Bun is roughly **~1.4x faster** on most operations (median across ~40 tests). Blob multipart uploads see the largest gain (~20x) thanks to Bun's native `Blob.slice()`. Results are approximate and will vary by environment.
|
|
46
53
|
|
|
47
54
|
## Table of Contents
|
|
48
55
|
|
|
@@ -519,9 +526,15 @@ const url = await s3.getPresignedUrl('GET', 'report.pdf', 3600, {
|
|
|
519
526
|
|
|
520
527
|
```typescript
|
|
521
528
|
// Upload URL that requires Content-Type β client MUST send this exact header
|
|
522
|
-
const url = await s3.getPresignedUrl(
|
|
523
|
-
'
|
|
524
|
-
|
|
529
|
+
const url = await s3.getPresignedUrl(
|
|
530
|
+
'PUT',
|
|
531
|
+
'uploads/data.json',
|
|
532
|
+
300,
|
|
533
|
+
{},
|
|
534
|
+
{
|
|
535
|
+
'Content-Type': 'application/json',
|
|
536
|
+
},
|
|
537
|
+
);
|
|
525
538
|
|
|
526
539
|
await fetch(url, {
|
|
527
540
|
method: 'PUT',
|
|
@@ -739,7 +752,7 @@ If you figure out a solution to your question or problem on your own, please con
|
|
|
739
752
|
|
|
740
753
|
## License
|
|
741
754
|
|
|
742
|
-
This project is licensed under the MIT License - see the [LICENSE
|
|
755
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
743
756
|
|
|
744
757
|
## Sponsor This Project
|
|
745
758
|
|
package/dist/s3mini.d.ts
CHANGED
|
@@ -164,6 +164,7 @@ declare class S3mini {
|
|
|
164
164
|
readonly logger?: Logger;
|
|
165
165
|
readonly _fetch: typeof fetch;
|
|
166
166
|
readonly minPartSize: number;
|
|
167
|
+
private readonly _bun?;
|
|
167
168
|
private signingKeyDate?;
|
|
168
169
|
private signingKey?;
|
|
169
170
|
constructor({ accessKeyId, secretAccessKey, endpoint, region, requestSizeInBytes, requestAbortTimeout, logger, fetch, minPartSize, }: S3Config);
|
|
@@ -175,6 +176,8 @@ declare class S3mini {
|
|
|
175
176
|
* @returns true if both accessKeyId and secretAccessKey are non-empty.
|
|
176
177
|
*/
|
|
177
178
|
private _hasCredentials;
|
|
179
|
+
/** Run a read op via Bun-native S3, returning null on NoSuchKey. */
|
|
180
|
+
private _bunRead;
|
|
178
181
|
private _ensureValidUrl;
|
|
179
182
|
private _validateMethodIsGetOrHead;
|
|
180
183
|
private _checkKey;
|
|
@@ -249,6 +252,10 @@ declare class S3mini {
|
|
|
249
252
|
private _parseListObjectsResponse;
|
|
250
253
|
private _extractObjectsFromResponse;
|
|
251
254
|
private _extractContinuationToken;
|
|
255
|
+
private _bunListAll;
|
|
256
|
+
private _bunFetchPage;
|
|
257
|
+
private _bunNextCursor;
|
|
258
|
+
private _bunMapListResult;
|
|
252
259
|
/**
|
|
253
260
|
* Lists multipart uploads in the bucket.
|
|
254
261
|
* This method sends a request to list multipart uploads in the specified bucket.
|
|
@@ -579,6 +586,9 @@ declare class S3mini {
|
|
|
579
586
|
*/
|
|
580
587
|
deleteObject(key: string): Promise<boolean>;
|
|
581
588
|
private _deleteObjectsProcess;
|
|
589
|
+
private _sendDeleteRequest;
|
|
590
|
+
private _markDeletedKeys;
|
|
591
|
+
private _logDeleteErrors;
|
|
582
592
|
/**
|
|
583
593
|
* Deletes multiple objects from the bucket.
|
|
584
594
|
* @param {string[]} keys - An array of object keys to delete.
|
package/dist/s3mini.js
CHANGED
|
@@ -31,6 +31,31 @@ 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
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Compare two strings by code point, as required for AWS SigV4 canonical
|
|
51
|
+
* ordering of query parameters and headers. `localeCompare` MUST NOT be used
|
|
52
|
+
* here: it is locale-aware and case-insensitive by default, so it mis-orders
|
|
53
|
+
* mixed-case names (e.g. `partNumber` before `X-Amz-*`) and breaks signatures.
|
|
54
|
+
* @param a First string
|
|
55
|
+
* @param b Second string
|
|
56
|
+
* @returns -1, 0, or 1
|
|
57
|
+
*/
|
|
58
|
+
const byCodePoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
34
59
|
const ENCODR = new TextEncoder();
|
|
35
60
|
const chunkSize = 0x8000; // 32KB chunks
|
|
36
61
|
const HEX_CHARS = new Uint8Array([48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102]);
|
|
@@ -398,6 +423,7 @@ class S3mini {
|
|
|
398
423
|
logger;
|
|
399
424
|
_fetch;
|
|
400
425
|
minPartSize;
|
|
426
|
+
_bun;
|
|
401
427
|
signingKeyDate;
|
|
402
428
|
signingKey;
|
|
403
429
|
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 +438,16 @@ class S3mini {
|
|
|
412
438
|
this.logger = logger;
|
|
413
439
|
this._fetch = (input, init) => fetch(input, init);
|
|
414
440
|
this.minPartSize = minPartSize;
|
|
441
|
+
if (isBun) {
|
|
442
|
+
const { S3Client } = globalThis.Bun;
|
|
443
|
+
this._bun = new S3Client({
|
|
444
|
+
accessKeyId,
|
|
445
|
+
secretAccessKey,
|
|
446
|
+
endpoint: extractBaseEndpoint(this.endpoint, this.bucketName),
|
|
447
|
+
region: this.region,
|
|
448
|
+
bucket: this.bucketName,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
415
451
|
}
|
|
416
452
|
_sanitize(obj) {
|
|
417
453
|
if (typeof obj !== 'object' || obj === null) {
|
|
@@ -472,6 +508,18 @@ class S3mini {
|
|
|
472
508
|
_hasCredentials() {
|
|
473
509
|
return this.#accessKeyId.trim().length > 0 && this.#secretAccessKey.trim().length > 0;
|
|
474
510
|
}
|
|
511
|
+
/** Run a read op via Bun-native S3, returning null on NoSuchKey. */
|
|
512
|
+
async _bunRead(key, op) {
|
|
513
|
+
try {
|
|
514
|
+
return await op(this._bun.file(key));
|
|
515
|
+
}
|
|
516
|
+
catch (e) {
|
|
517
|
+
if (e?.code === 'NoSuchKey') {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
throw e;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
475
523
|
_ensureValidUrl(raw) {
|
|
476
524
|
const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
477
525
|
try {
|
|
@@ -588,19 +636,12 @@ class S3mini {
|
|
|
588
636
|
headers[HEADER_AMZ_DATE] = fullDatetime;
|
|
589
637
|
headers[HEADER_HOST] = url.host;
|
|
590
638
|
const ignoredHeaders = new Set(['authorization', 'content-length', 'content-type', 'user-agent']);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
canonicalHeaders += '\n';
|
|
598
|
-
signedHeaders += ';';
|
|
599
|
-
}
|
|
600
|
-
canonicalHeaders += `${lowerKey}:${String(value).trim()}`;
|
|
601
|
-
signedHeaders += lowerKey;
|
|
602
|
-
}
|
|
603
|
-
}
|
|
639
|
+
const sortedHeaders = Object.entries(headers)
|
|
640
|
+
.map(([key, value]) => [key.toLowerCase(), String(value).trim()])
|
|
641
|
+
.filter(([lowerKey]) => !ignoredHeaders.has(lowerKey))
|
|
642
|
+
.sort(([a], [b]) => byCodePoint(a, b));
|
|
643
|
+
const canonicalHeaders = sortedHeaders.map(([k, v]) => `${k}:${v}`).join('\n');
|
|
644
|
+
const signedHeaders = sortedHeaders.map(([k]) => k).join(';');
|
|
604
645
|
const canonicalRequest = `${method}\n${url.pathname}\n${this._buildCanonicalQueryString(query)}\n${canonicalHeaders}\n\n${signedHeaders}\n${UNSIGNED_PAYLOAD}`;
|
|
605
646
|
const stringToSign = `${AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
|
|
606
647
|
if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
|
|
@@ -677,32 +718,24 @@ class S3mini {
|
|
|
677
718
|
}
|
|
678
719
|
_extractBucketName() {
|
|
679
720
|
const url = this.endpoint;
|
|
680
|
-
//
|
|
681
|
-
const
|
|
682
|
-
if (
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
721
|
+
// Path-style: bucket is the first non-empty path segment
|
|
722
|
+
const firstSegment = url.pathname.split('/').find(Boolean);
|
|
723
|
+
if (firstSegment) {
|
|
724
|
+
return firstSegment;
|
|
725
|
+
}
|
|
726
|
+
// Virtual-hosted style: bucket is the first subdomain label
|
|
727
|
+
const hostname = url.hostname;
|
|
728
|
+
// IP addresses (v4: digits+dots, v6: contains colons) can't carry a bucket subdomain
|
|
729
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
|
|
730
|
+
return '';
|
|
686
731
|
}
|
|
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
|
-
}
|
|
732
|
+
const labels = hostname.split('.');
|
|
733
|
+
// Need β₯3 labels for virtual-hosted (bucket.service.tld)
|
|
734
|
+
// Single-label (localhost) or two-label (example.com) have no room for a bucket subdomain
|
|
735
|
+
if (labels.length < 3) {
|
|
736
|
+
return '';
|
|
703
737
|
}
|
|
704
|
-
|
|
705
|
-
return hostParts[0] || '';
|
|
738
|
+
return labels[0];
|
|
706
739
|
}
|
|
707
740
|
/**
|
|
708
741
|
* Checks if a bucket exists.
|
|
@@ -732,6 +765,12 @@ class S3mini {
|
|
|
732
765
|
this._checkDelimiter(delimiter);
|
|
733
766
|
this._checkPrefix(prefix);
|
|
734
767
|
this._checkOpts(opts);
|
|
768
|
+
if (this._bun && delimiter === '/') {
|
|
769
|
+
const extraKeys = Object.keys(opts).filter(k => k !== 'delimiter');
|
|
770
|
+
if (extraKeys.length === 0) {
|
|
771
|
+
return this._bunListAll(prefix, maxKeys, opts.delimiter);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
735
774
|
const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
|
|
736
775
|
const unlimited = !(maxKeys && maxKeys > 0);
|
|
737
776
|
let remaining = unlimited ? Infinity : maxKeys;
|
|
@@ -877,6 +916,71 @@ class S3mini {
|
|
|
877
916
|
response.NextMarker ||
|
|
878
917
|
response.nextMarker);
|
|
879
918
|
}
|
|
919
|
+
async _bunListAll(prefix, maxKeys, delimiter) {
|
|
920
|
+
const unlimited = !(maxKeys && maxKeys > 0);
|
|
921
|
+
let remaining = unlimited ? Infinity : maxKeys;
|
|
922
|
+
let startAfter;
|
|
923
|
+
const all = [];
|
|
924
|
+
try {
|
|
925
|
+
do {
|
|
926
|
+
const batchSize = Math.min(remaining === Infinity ? 1000 : remaining, 1000);
|
|
927
|
+
const res = await this._bunFetchPage(prefix, delimiter, batchSize, startAfter);
|
|
928
|
+
const mapped = this._bunMapListResult(res);
|
|
929
|
+
all.push(...mapped);
|
|
930
|
+
if (!unlimited) {
|
|
931
|
+
remaining -= mapped.length;
|
|
932
|
+
}
|
|
933
|
+
startAfter = this._bunNextCursor(res);
|
|
934
|
+
} while (startAfter && remaining > 0);
|
|
935
|
+
}
|
|
936
|
+
catch (e) {
|
|
937
|
+
if (e?.code === 'NoSuchBucket') {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
throw e;
|
|
941
|
+
}
|
|
942
|
+
return all;
|
|
943
|
+
}
|
|
944
|
+
_bunFetchPage(prefix, delimiter, maxKeys, startAfter) {
|
|
945
|
+
return this._bun.list({
|
|
946
|
+
prefix: prefix || undefined,
|
|
947
|
+
delimiter: delimiter || '/',
|
|
948
|
+
maxKeys,
|
|
949
|
+
...(startAfter ? { startAfter } : {}),
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
_bunNextCursor(res) {
|
|
953
|
+
if (!res.isTruncated) {
|
|
954
|
+
return undefined;
|
|
955
|
+
}
|
|
956
|
+
return res.contents?.length ? res.contents.at(-1).key : undefined;
|
|
957
|
+
}
|
|
958
|
+
_bunMapListResult(res) {
|
|
959
|
+
const objects = [];
|
|
960
|
+
if (res.contents) {
|
|
961
|
+
for (const item of res.contents) {
|
|
962
|
+
objects.push({
|
|
963
|
+
Key: item.key,
|
|
964
|
+
Size: item.size,
|
|
965
|
+
LastModified: item.lastModified instanceof Date ? item.lastModified : new Date(item.lastModified),
|
|
966
|
+
ETag: item.etag ?? '',
|
|
967
|
+
StorageClass: item.storageClass ?? '',
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (res.commonPrefixes) {
|
|
972
|
+
for (const item of res.commonPrefixes) {
|
|
973
|
+
objects.push({
|
|
974
|
+
Key: item.prefix,
|
|
975
|
+
Size: 0,
|
|
976
|
+
LastModified: new Date(0),
|
|
977
|
+
ETag: '',
|
|
978
|
+
StorageClass: '',
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return objects;
|
|
983
|
+
}
|
|
880
984
|
/**
|
|
881
985
|
* Lists multipart uploads in the bucket.
|
|
882
986
|
* This method sends a request to list multipart uploads in the specified bucket.
|
|
@@ -923,7 +1027,9 @@ class S3mini {
|
|
|
923
1027
|
* @returns A promise that resolves to the object data (string) or null if not found.
|
|
924
1028
|
*/
|
|
925
1029
|
async getObject(key, opts = {}, ssecHeaders) {
|
|
926
|
-
|
|
1030
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1031
|
+
return this._bunRead(key, f => f.text());
|
|
1032
|
+
}
|
|
927
1033
|
const res = await this._signedRequest('GET', key, {
|
|
928
1034
|
query: opts, // use opts.query if it exists, otherwise use an empty object
|
|
929
1035
|
tolerated: [200, 404, 412, 304],
|
|
@@ -965,6 +1071,9 @@ class S3mini {
|
|
|
965
1071
|
* @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
|
|
966
1072
|
*/
|
|
967
1073
|
async getObjectArrayBuffer(key, opts = {}, ssecHeaders) {
|
|
1074
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1075
|
+
return this._bunRead(key, f => f.arrayBuffer());
|
|
1076
|
+
}
|
|
968
1077
|
const res = await this._signedRequest('GET', key, {
|
|
969
1078
|
query: opts,
|
|
970
1079
|
tolerated: [200, 404, 412, 304],
|
|
@@ -985,6 +1094,9 @@ class S3mini {
|
|
|
985
1094
|
* @returns A promise that resolves to the object data as JSON or null if not found.
|
|
986
1095
|
*/
|
|
987
1096
|
async getObjectJSON(key, opts = {}, ssecHeaders) {
|
|
1097
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1098
|
+
return this._bunRead(key, f => f.json());
|
|
1099
|
+
}
|
|
988
1100
|
const res = await this._signedRequest('GET', key, {
|
|
989
1101
|
query: opts,
|
|
990
1102
|
tolerated: [200, 404, 412, 304],
|
|
@@ -1005,6 +1117,19 @@ class S3mini {
|
|
|
1005
1117
|
* @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
|
|
1006
1118
|
*/
|
|
1007
1119
|
async getObjectWithETag(key, opts = {}, ssecHeaders) {
|
|
1120
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1121
|
+
try {
|
|
1122
|
+
const f = this._bun.file(key);
|
|
1123
|
+
const [stat, data] = await Promise.all([f.stat(), f.arrayBuffer()]);
|
|
1124
|
+
return { etag: sanitizeETag(stat.etag), data };
|
|
1125
|
+
}
|
|
1126
|
+
catch (e) {
|
|
1127
|
+
if (e?.code === 'NoSuchKey') {
|
|
1128
|
+
return { etag: null, data: null };
|
|
1129
|
+
}
|
|
1130
|
+
throw e;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1008
1133
|
try {
|
|
1009
1134
|
const res = await this._signedRequest('GET', key, {
|
|
1010
1135
|
query: opts,
|
|
@@ -1039,6 +1164,28 @@ class S3mini {
|
|
|
1039
1164
|
* @returns A promise that resolves to the Response object.
|
|
1040
1165
|
*/
|
|
1041
1166
|
async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo, opts = {}, ssecHeaders) {
|
|
1167
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1168
|
+
const f = this._bun.file(key);
|
|
1169
|
+
if (wholeFile) {
|
|
1170
|
+
const buf = await f.arrayBuffer();
|
|
1171
|
+
const stat = await f.stat();
|
|
1172
|
+
return new Response(buf, {
|
|
1173
|
+
status: 200,
|
|
1174
|
+
headers: { 'content-length': String(stat.size), etag: stat.etag, 'content-type': stat.type },
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
const sliced = rangeTo === undefined ? f.slice(rangeFrom) : f.slice(rangeFrom, rangeTo);
|
|
1178
|
+
const buf = await sliced.arrayBuffer();
|
|
1179
|
+
const stat = await f.stat();
|
|
1180
|
+
const endByte = rangeTo === undefined ? stat.size - 1 : rangeTo - 1;
|
|
1181
|
+
return new Response(buf, {
|
|
1182
|
+
status: 206,
|
|
1183
|
+
headers: {
|
|
1184
|
+
'content-range': `bytes ${rangeFrom}-${endByte}/${stat.size}`,
|
|
1185
|
+
'content-length': String(buf.byteLength),
|
|
1186
|
+
},
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1042
1189
|
let rangeHdr = {};
|
|
1043
1190
|
if (!wholeFile) {
|
|
1044
1191
|
rangeHdr =
|
|
@@ -1059,6 +1206,9 @@ class S3mini {
|
|
|
1059
1206
|
*/
|
|
1060
1207
|
async getContentLength(key, ssecHeaders) {
|
|
1061
1208
|
try {
|
|
1209
|
+
if (this._bun && !ssecHeaders) {
|
|
1210
|
+
return (await this._bun.file(key).stat()).size;
|
|
1211
|
+
}
|
|
1062
1212
|
const res = await this._signedRequest('HEAD', key, {
|
|
1063
1213
|
headers: ssecHeaders ? { ...ssecHeaders } : undefined,
|
|
1064
1214
|
});
|
|
@@ -1080,6 +1230,9 @@ class S3mini {
|
|
|
1080
1230
|
* @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
|
|
1081
1231
|
*/
|
|
1082
1232
|
async objectExists(key, opts = {}) {
|
|
1233
|
+
if (this._bun && !Object.keys(opts).length) {
|
|
1234
|
+
return this._bun.file(key).exists();
|
|
1235
|
+
}
|
|
1083
1236
|
const res = await this._signedRequest('HEAD', key, {
|
|
1084
1237
|
query: opts,
|
|
1085
1238
|
tolerated: [200, 404, 412, 304],
|
|
@@ -1106,6 +1259,15 @@ class S3mini {
|
|
|
1106
1259
|
* }
|
|
1107
1260
|
*/
|
|
1108
1261
|
async getEtag(key, opts = {}, ssecHeaders) {
|
|
1262
|
+
if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
|
|
1263
|
+
return this._bunRead(key, async (f) => {
|
|
1264
|
+
const { etag } = await f.stat();
|
|
1265
|
+
if (!etag) {
|
|
1266
|
+
throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
|
|
1267
|
+
}
|
|
1268
|
+
return sanitizeETag(etag);
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1109
1271
|
const res = await this._signedRequest('HEAD', key, {
|
|
1110
1272
|
query: opts,
|
|
1111
1273
|
tolerated: [200, 304, 404, 412],
|
|
@@ -1141,6 +1303,12 @@ class S3mini {
|
|
|
1141
1303
|
* await s3.putObject('image.png', buffer, 'image/png');
|
|
1142
1304
|
*/
|
|
1143
1305
|
async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders, additionalHeaders, contentLength) {
|
|
1306
|
+
if (this._bun && !ssecHeaders && !additionalHeaders) {
|
|
1307
|
+
const f = this._bun.file(key);
|
|
1308
|
+
await f.write(data, { type: fileType });
|
|
1309
|
+
const { etag } = await f.stat();
|
|
1310
|
+
return new Response(null, { status: 200, headers: etag ? { etag } : {} });
|
|
1311
|
+
}
|
|
1144
1312
|
const size = contentLength ?? getByteSize(data);
|
|
1145
1313
|
return this._signedRequest('PUT', key, {
|
|
1146
1314
|
body: data,
|
|
@@ -1173,6 +1341,14 @@ class S3mini {
|
|
|
1173
1341
|
* await s3.putAnyObject('image.png', buffer, 'image/png');
|
|
1174
1342
|
*/
|
|
1175
1343
|
async putAnyObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders, additionalHeaders, contentLength) {
|
|
1344
|
+
// Bun handles multipart automatically for large files
|
|
1345
|
+
if (this._bun && !ssecHeaders && !additionalHeaders) {
|
|
1346
|
+
this._checkKey(key);
|
|
1347
|
+
const f = this._bun.file(key);
|
|
1348
|
+
await f.write(data, { type: fileType });
|
|
1349
|
+
const { etag } = await f.stat();
|
|
1350
|
+
return this._createSuccessResponse(etag || '');
|
|
1351
|
+
}
|
|
1176
1352
|
const size = contentLength ?? getByteSize(data);
|
|
1177
1353
|
// Single PUT for small files
|
|
1178
1354
|
if (!Number.isNaN(size) && size <= this.minPartSize) {
|
|
@@ -1635,72 +1811,74 @@ class S3mini {
|
|
|
1635
1811
|
* @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
|
|
1636
1812
|
*/
|
|
1637
1813
|
async deleteObject(key) {
|
|
1814
|
+
if (this._bun) {
|
|
1815
|
+
await this._bun.file(key).delete();
|
|
1816
|
+
return true;
|
|
1817
|
+
}
|
|
1638
1818
|
const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
|
|
1639
1819
|
return res.status === 200 || res.status === 204;
|
|
1640
1820
|
}
|
|
1641
1821
|
async _deleteObjectsProcess(keys) {
|
|
1822
|
+
const out = await this._sendDeleteRequest(keys);
|
|
1823
|
+
const resultMap = new Map(keys.map(k => [k, false]));
|
|
1824
|
+
this._markDeletedKeys(out, resultMap);
|
|
1825
|
+
this._logDeleteErrors(out, resultMap);
|
|
1826
|
+
return keys.map(key => resultMap.get(key) || false);
|
|
1827
|
+
}
|
|
1828
|
+
async _sendDeleteRequest(keys) {
|
|
1642
1829
|
const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
|
|
1643
1830
|
const xmlBody = '<Delete>' + objectsXml + '</Delete>';
|
|
1644
|
-
const query = { delete: '' };
|
|
1645
1831
|
const sha256base64 = base64FromBuffer(await sha256(xmlBody));
|
|
1646
|
-
const headers = {
|
|
1647
|
-
[HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
|
|
1648
|
-
[HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1649
|
-
[HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1650
|
-
};
|
|
1651
1832
|
const res = await this._signedRequest('POST', '', {
|
|
1652
|
-
query,
|
|
1833
|
+
query: { delete: '' },
|
|
1653
1834
|
body: xmlBody,
|
|
1654
|
-
headers
|
|
1835
|
+
headers: {
|
|
1836
|
+
[HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
|
|
1837
|
+
[HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
|
|
1838
|
+
[HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
|
|
1839
|
+
},
|
|
1655
1840
|
withQuery: true,
|
|
1656
1841
|
});
|
|
1657
1842
|
const parsed = parseXml(await res.text());
|
|
1658
1843
|
if (!parsed || typeof parsed !== 'object') {
|
|
1659
1844
|
throw new Error(`${ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
|
|
1660
1845
|
}
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
resultMap.set(key, false);
|
|
1665
|
-
}
|
|
1846
|
+
return (parsed.DeleteResult || parsed.deleteResult || parsed);
|
|
1847
|
+
}
|
|
1848
|
+
_markDeletedKeys(out, resultMap) {
|
|
1666
1849
|
const deleted = out.deleted || out.Deleted;
|
|
1667
|
-
if (deleted) {
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
}
|
|
1850
|
+
if (!deleted) {
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
const items = Array.isArray(deleted) ? deleted : [deleted];
|
|
1854
|
+
for (const item of items) {
|
|
1855
|
+
if (item && typeof item === 'object') {
|
|
1856
|
+
const key = item.key || item.Key;
|
|
1857
|
+
if (key && typeof key === 'string') {
|
|
1858
|
+
resultMap.set(key, true);
|
|
1677
1859
|
}
|
|
1678
1860
|
}
|
|
1679
1861
|
}
|
|
1680
|
-
|
|
1862
|
+
}
|
|
1863
|
+
_logDeleteErrors(out, resultMap) {
|
|
1681
1864
|
const errors = out.error || out.Error;
|
|
1682
|
-
if (errors) {
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
message: message || 'Unknown error',
|
|
1697
|
-
});
|
|
1698
|
-
}
|
|
1865
|
+
if (!errors) {
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
const items = Array.isArray(errors) ? errors : [errors];
|
|
1869
|
+
for (const item of items) {
|
|
1870
|
+
if (item && typeof item === 'object') {
|
|
1871
|
+
const obj = item;
|
|
1872
|
+
const key = obj.key || obj.Key;
|
|
1873
|
+
if (key && typeof key === 'string') {
|
|
1874
|
+
resultMap.set(key, false);
|
|
1875
|
+
this._log('warn', `Failed to delete object: ${key}`, {
|
|
1876
|
+
code: obj.code || obj.Code || 'Unknown',
|
|
1877
|
+
message: obj.message || obj.Message || 'Unknown error',
|
|
1878
|
+
});
|
|
1699
1879
|
}
|
|
1700
1880
|
}
|
|
1701
1881
|
}
|
|
1702
|
-
// Return boolean array in the same order as input keys
|
|
1703
|
-
return keys.map(key => resultMap.get(key) || false);
|
|
1704
1882
|
}
|
|
1705
1883
|
/**
|
|
1706
1884
|
* Deletes multiple objects from the bucket.
|
|
@@ -1781,8 +1959,9 @@ class S3mini {
|
|
|
1781
1959
|
return '';
|
|
1782
1960
|
}
|
|
1783
1961
|
return Object.keys(queryParams)
|
|
1784
|
-
.map(key =>
|
|
1785
|
-
.sort((a, b) => a
|
|
1962
|
+
.map((key) => [encodeURIComponent(key), encodeURIComponent(String(queryParams[key]))])
|
|
1963
|
+
.sort(([a], [b]) => byCodePoint(a, b))
|
|
1964
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
1786
1965
|
.join('&');
|
|
1787
1966
|
}
|
|
1788
1967
|
/**
|
|
@@ -1815,6 +1994,9 @@ class S3mini {
|
|
|
1815
1994
|
if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
|
|
1816
1995
|
throw new TypeError(`${ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
|
|
1817
1996
|
}
|
|
1997
|
+
if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
|
|
1998
|
+
return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
|
|
1999
|
+
}
|
|
1818
2000
|
return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
|
|
1819
2001
|
}
|
|
1820
2002
|
async _presign(method, keyPath, expiresIn, queryParams, headers) {
|
|
@@ -1837,7 +2019,7 @@ class S3mini {
|
|
|
1837
2019
|
headerEntries.push([lowerKey, String(value).trim()]);
|
|
1838
2020
|
}
|
|
1839
2021
|
}
|
|
1840
|
-
headerEntries.sort(([a], [b]) => a
|
|
2022
|
+
headerEntries.sort(([a], [b]) => byCodePoint(a, b));
|
|
1841
2023
|
const canonicalHeaders = headerEntries.map(([k, v]) => `${k}:${v}`).join('\n');
|
|
1842
2024
|
const signedHeaders = headerEntries.map(([k]) => k).join(';');
|
|
1843
2025
|
const allQueryParams = {
|