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 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
- [[github](https://github.com/good-lly/s3mini)]
6
- [[issues](https://github.com/good-lly/s3mini/issues)]
5
+ ![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white)
6
+ ![Bun](https://img.shields.io/badge/Bun-%23000000.svg?style=for-the-badge&logo=bun&logoColor=white)
7
+ ![Cloudflare](https://img.shields.io/badge/Cloudflare-F38020?style=for-the-badge&logo=Cloudflare&logoColor=white)
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: averages β‰ˆ15 % more ops/s and only ~20 KB (minified, not gzipped).
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
- [![GitHub commit activity (branch)](https://img.shields.io/github/commit-activity/m/good-lly/s3mini/dev?color=green)](https://github.com/good-lly/s3mini/commits/dev)
29
- [![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/good-lly/s3mini)](https://github.com/good-lly/s3mini/issues)
30
- [![CodeQL Advanced](https://github.com/good-lly/s3mini/actions/workflows/codeql.yml/badge.svg?branch=dev)](https://github.com/good-lly/s3mini/actions/workflows/codeql.yml)
31
- [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=good-lly_s3mini&metric=bugs)](https://sonarcloud.io/summary/new_code?id=good-lly_s3mini)
32
- [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=good-lly_s3mini&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=good-lly_s3mini)
33
- [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=good-lly_s3mini&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=good-lly_s3mini)
34
- [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=good-lly_s3mini&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=good-lly_s3mini)
35
- [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=good-lly_s3mini&metric=sqale_index)](https://sonarcloud.io/summary/new_code?id=good-lly_s3mini)
36
- [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=good-lly_s3mini&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=good-lly_s3mini)
37
- [![Test:e2e(all)](https://github.com/good-lly/s3mini/actions/workflows/test-e2e.yml/badge.svg?branch=dev)](https://github.com/good-lly/s3mini/actions/workflows/test-e2e.yml)
38
-
39
- ![GitHub Repo stars](https://img.shields.io/github/stars/good-lly/s3mini?style=social)
32
+ [![Last commit](https://img.shields.io/gitea/last-commit/thinking_tools/s3mini/dev?gitea_url=https%3A%2F%2Fcodeberg.org&color=green)](https://codeberg.org/thinking_tools/s3mini/commits/branch/dev)
33
+ [![Issues](https://img.shields.io/gitea/issues/open/thinking_tools/s3mini?gitea_url=https%3A%2F%2Fcodeberg.org)](https://codeberg.org/thinking_tools/s3mini/issues)
34
+ [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=thinking-tools-at-codeberg_s3mini&metric=bugs&branch=dev)](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
35
+ [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=thinking-tools-at-codeberg_s3mini&metric=reliability_rating&branch=dev)](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
36
+ [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=thinking-tools-at-codeberg_s3mini&metric=security_rating&branch=dev)](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
37
+ [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=thinking-tools-at-codeberg_s3mini&metric=vulnerabilities&branch=dev)](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
38
+ [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=thinking-tools-at-codeberg_s3mini&metric=sqale_index&branch=dev)](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
39
+ [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=thinking-tools-at-codeberg_s3mini&metric=sqale_rating&branch=dev)](https://sonarcloud.io/summary/new_code?id=thinking-tools-at-codeberg_s3mini&branch=dev)
40
+ [![Test:e2e(all)](https://codeberg.org/thinking_tools/s3mini/actions/workflows/test-e2e.yml/badge.svg?branch=dev)](https://codeberg.org/thinking_tools/s3mini/actions?workflow=test-e2e.yml)
41
+
42
+ [![Codeberg Repo stars](https://img.shields.io/gitea/stars/thinking_tools/s3mini?gitea_url=https%3A%2F%2Fcodeberg.org&style=social&label=Codeberg)](https://codeberg.org/thinking_tools/s3mini/stars) **+** [![GitHub Repo stars](https://img.shields.io/github/stars/good-lly/s3mini?style=social&label=GitHub)](https://github.com/good-lly/s3mini/stargazers)
40
43
  [![NPM Downloads](https://img.shields.io/npm/dm/s3mini)](https://www.npmjs.com/package/s3mini)
41
44
  ![NPM Version](https://img.shields.io/npm/v/s3mini?color=green)
42
45
  ![npm package minimized gzipped size](https://img.shields.io/bundlejs/size/s3mini?color=green)
43
- ![GitHub License](https://img.shields.io/github/license/good-lly/s3mini)
46
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
- <a href="https://github.com/good-lly/s3mini/issues/"> <img src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg" alt="Contributions welcome" /></a>
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('PUT', 'uploads/data.json', 300, {}, {
523
- 'Content-Type': 'application/json',
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.md](LICENSE.md) file for details.
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
- let canonicalHeaders = '';
592
- let signedHeaders = '';
593
- for (const [key, value] of Object.entries(headers).sort(([a], [b]) => a.localeCompare(b))) {
594
- const lowerKey = key.toLowerCase();
595
- if (!ignoredHeaders.has(lowerKey)) {
596
- if (canonicalHeaders) {
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
- // First check if bucket is in the pathname (path-style URLs)
681
- const pathSegments = url.pathname.split('/').filter(Boolean);
682
- if (pathSegments.length > 0) {
683
- if (typeof pathSegments[0] === 'string') {
684
- return pathSegments[0];
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
- // Otherwise extract from subdomain (virtual-hosted-style URLs)
688
- const hostParts = url.hostname.split('.');
689
- // Common patterns:
690
- // bucket-name.s3.amazonaws.com
691
- // bucket-name.s3.region.amazonaws.com
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
- // Fallback: use the first subdomain
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
- // if ssecHeaders is set, add it to headers
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
- const out = (parsed.DeleteResult || parsed.deleteResult || parsed);
1662
- const resultMap = new Map();
1663
- for (const key of keys) {
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
- const deletedArray = Array.isArray(deleted) ? deleted : [deleted];
1669
- for (const item of deletedArray) {
1670
- if (item && typeof item === 'object') {
1671
- const obj = item;
1672
- // Check both key and Key
1673
- const key = obj.key || obj.Key;
1674
- if (key && typeof key === 'string') {
1675
- resultMap.set(key, true);
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
- // Handle errors (check both cases)
1862
+ }
1863
+ _logDeleteErrors(out, resultMap) {
1681
1864
  const errors = out.error || out.Error;
1682
- if (errors) {
1683
- const errorsArray = Array.isArray(errors) ? errors : [errors];
1684
- for (const item of errorsArray) {
1685
- if (item && typeof item === 'object') {
1686
- const obj = item;
1687
- // Check both cases for all properties
1688
- const key = obj.key || obj.Key;
1689
- const code = obj.code || obj.Code;
1690
- const message = obj.message || obj.Message;
1691
- if (key && typeof key === 'string') {
1692
- resultMap.set(key, false);
1693
- // Optionally log the error for debugging
1694
- this._log('warn', `Failed to delete object: ${key}`, {
1695
- code: code || 'Unknown',
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 => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
1785
- .sort((a, b) => a.localeCompare(b))
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.localeCompare(b));
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 = {