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 CHANGED
@@ -2,13 +2,17 @@
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
+ ![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
+
5
9
  [[github](https://github.com/good-lly/s3mini)]
6
10
  [[issues](https://github.com/good-lly/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.
@@ -44,6 +48,10 @@ Dev:
44
48
 
45
49
  <a href="https://github.com/good-lly/s3mini/issues/"> <img src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg" alt="Contributions welcome" /></a>
46
50
 
51
+ ### Bun vs Node
52
+
53
+ 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.
54
+
47
55
  ## Table of Contents
48
56
 
49
57
  - [Installation](#installation)
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,21 @@ 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
+ };
34
49
  const ENCODR = new TextEncoder();
35
50
  const chunkSize = 0x8000; // 32KB chunks
36
51
  const HEX_CHARS = new Uint8Array([48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102]);
@@ -398,6 +413,7 @@ class S3mini {
398
413
  logger;
399
414
  _fetch;
400
415
  minPartSize;
416
+ _bun;
401
417
  signingKeyDate;
402
418
  signingKey;
403
419
  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 +428,16 @@ class S3mini {
412
428
  this.logger = logger;
413
429
  this._fetch = (input, init) => fetch(input, init);
414
430
  this.minPartSize = minPartSize;
431
+ if (isBun) {
432
+ const { S3Client } = globalThis.Bun;
433
+ this._bun = new S3Client({
434
+ accessKeyId,
435
+ secretAccessKey,
436
+ endpoint: extractBaseEndpoint(this.endpoint, this.bucketName),
437
+ region: this.region,
438
+ bucket: this.bucketName,
439
+ });
440
+ }
415
441
  }
416
442
  _sanitize(obj) {
417
443
  if (typeof obj !== 'object' || obj === null) {
@@ -472,6 +498,18 @@ class S3mini {
472
498
  _hasCredentials() {
473
499
  return this.#accessKeyId.trim().length > 0 && this.#secretAccessKey.trim().length > 0;
474
500
  }
501
+ /** Run a read op via Bun-native S3, returning null on NoSuchKey. */
502
+ async _bunRead(key, op) {
503
+ try {
504
+ return await op(this._bun.file(key));
505
+ }
506
+ catch (e) {
507
+ if (e?.code === 'NoSuchKey') {
508
+ return null;
509
+ }
510
+ throw e;
511
+ }
512
+ }
475
513
  _ensureValidUrl(raw) {
476
514
  const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
477
515
  try {
@@ -677,32 +715,24 @@ class S3mini {
677
715
  }
678
716
  _extractBucketName() {
679
717
  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
- }
718
+ // Path-style: bucket is the first non-empty path segment
719
+ const firstSegment = url.pathname.split('/').find(Boolean);
720
+ if (firstSegment) {
721
+ return firstSegment;
722
+ }
723
+ // Virtual-hosted style: bucket is the first subdomain label
724
+ const hostname = url.hostname;
725
+ // IP addresses (v4: digits+dots, v6: contains colons) can't carry a bucket subdomain
726
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
727
+ return '';
686
728
  }
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
- }
729
+ const labels = hostname.split('.');
730
+ // Need ≥3 labels for virtual-hosted (bucket.service.tld)
731
+ // Single-label (localhost) or two-label (example.com) have no room for a bucket subdomain
732
+ if (labels.length < 3) {
733
+ return '';
703
734
  }
704
- // Fallback: use the first subdomain
705
- return hostParts[0] || '';
735
+ return labels[0];
706
736
  }
707
737
  /**
708
738
  * Checks if a bucket exists.
@@ -732,6 +762,12 @@ class S3mini {
732
762
  this._checkDelimiter(delimiter);
733
763
  this._checkPrefix(prefix);
734
764
  this._checkOpts(opts);
765
+ if (this._bun && delimiter === '/') {
766
+ const extraKeys = Object.keys(opts).filter(k => k !== 'delimiter');
767
+ if (extraKeys.length === 0) {
768
+ return this._bunListAll(prefix, maxKeys, opts.delimiter);
769
+ }
770
+ }
735
771
  const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
736
772
  const unlimited = !(maxKeys && maxKeys > 0);
737
773
  let remaining = unlimited ? Infinity : maxKeys;
@@ -877,6 +913,71 @@ class S3mini {
877
913
  response.NextMarker ||
878
914
  response.nextMarker);
879
915
  }
916
+ async _bunListAll(prefix, maxKeys, delimiter) {
917
+ const unlimited = !(maxKeys && maxKeys > 0);
918
+ let remaining = unlimited ? Infinity : maxKeys;
919
+ let startAfter;
920
+ const all = [];
921
+ try {
922
+ do {
923
+ const batchSize = Math.min(remaining === Infinity ? 1000 : remaining, 1000);
924
+ const res = await this._bunFetchPage(prefix, delimiter, batchSize, startAfter);
925
+ const mapped = this._bunMapListResult(res);
926
+ all.push(...mapped);
927
+ if (!unlimited) {
928
+ remaining -= mapped.length;
929
+ }
930
+ startAfter = this._bunNextCursor(res);
931
+ } while (startAfter && remaining > 0);
932
+ }
933
+ catch (e) {
934
+ if (e?.code === 'NoSuchBucket') {
935
+ return null;
936
+ }
937
+ throw e;
938
+ }
939
+ return all;
940
+ }
941
+ _bunFetchPage(prefix, delimiter, maxKeys, startAfter) {
942
+ return this._bun.list({
943
+ prefix: prefix || undefined,
944
+ delimiter: delimiter || '/',
945
+ maxKeys,
946
+ ...(startAfter ? { startAfter } : {}),
947
+ });
948
+ }
949
+ _bunNextCursor(res) {
950
+ if (!res.isTruncated) {
951
+ return undefined;
952
+ }
953
+ return res.contents?.length ? res.contents.at(-1).key : undefined;
954
+ }
955
+ _bunMapListResult(res) {
956
+ const objects = [];
957
+ if (res.contents) {
958
+ for (const item of res.contents) {
959
+ objects.push({
960
+ Key: item.key,
961
+ Size: item.size,
962
+ LastModified: item.lastModified instanceof Date ? item.lastModified : new Date(item.lastModified),
963
+ ETag: item.etag ?? '',
964
+ StorageClass: item.storageClass ?? '',
965
+ });
966
+ }
967
+ }
968
+ if (res.commonPrefixes) {
969
+ for (const item of res.commonPrefixes) {
970
+ objects.push({
971
+ Key: item.prefix,
972
+ Size: 0,
973
+ LastModified: new Date(0),
974
+ ETag: '',
975
+ StorageClass: '',
976
+ });
977
+ }
978
+ }
979
+ return objects;
980
+ }
880
981
  /**
881
982
  * Lists multipart uploads in the bucket.
882
983
  * This method sends a request to list multipart uploads in the specified bucket.
@@ -923,7 +1024,9 @@ class S3mini {
923
1024
  * @returns A promise that resolves to the object data (string) or null if not found.
924
1025
  */
925
1026
  async getObject(key, opts = {}, ssecHeaders) {
926
- // if ssecHeaders is set, add it to headers
1027
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1028
+ return this._bunRead(key, f => f.text());
1029
+ }
927
1030
  const res = await this._signedRequest('GET', key, {
928
1031
  query: opts, // use opts.query if it exists, otherwise use an empty object
929
1032
  tolerated: [200, 404, 412, 304],
@@ -965,6 +1068,9 @@ class S3mini {
965
1068
  * @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
966
1069
  */
967
1070
  async getObjectArrayBuffer(key, opts = {}, ssecHeaders) {
1071
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1072
+ return this._bunRead(key, f => f.arrayBuffer());
1073
+ }
968
1074
  const res = await this._signedRequest('GET', key, {
969
1075
  query: opts,
970
1076
  tolerated: [200, 404, 412, 304],
@@ -985,6 +1091,9 @@ class S3mini {
985
1091
  * @returns A promise that resolves to the object data as JSON or null if not found.
986
1092
  */
987
1093
  async getObjectJSON(key, opts = {}, ssecHeaders) {
1094
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1095
+ return this._bunRead(key, f => f.json());
1096
+ }
988
1097
  const res = await this._signedRequest('GET', key, {
989
1098
  query: opts,
990
1099
  tolerated: [200, 404, 412, 304],
@@ -1005,6 +1114,19 @@ class S3mini {
1005
1114
  * @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
1006
1115
  */
1007
1116
  async getObjectWithETag(key, opts = {}, ssecHeaders) {
1117
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1118
+ try {
1119
+ const f = this._bun.file(key);
1120
+ const [stat, data] = await Promise.all([f.stat(), f.arrayBuffer()]);
1121
+ return { etag: sanitizeETag(stat.etag), data };
1122
+ }
1123
+ catch (e) {
1124
+ if (e?.code === 'NoSuchKey') {
1125
+ return { etag: null, data: null };
1126
+ }
1127
+ throw e;
1128
+ }
1129
+ }
1008
1130
  try {
1009
1131
  const res = await this._signedRequest('GET', key, {
1010
1132
  query: opts,
@@ -1039,6 +1161,28 @@ class S3mini {
1039
1161
  * @returns A promise that resolves to the Response object.
1040
1162
  */
1041
1163
  async getObjectRaw(key, wholeFile = true, rangeFrom = 0, rangeTo, opts = {}, ssecHeaders) {
1164
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1165
+ const f = this._bun.file(key);
1166
+ if (wholeFile) {
1167
+ const buf = await f.arrayBuffer();
1168
+ const stat = await f.stat();
1169
+ return new Response(buf, {
1170
+ status: 200,
1171
+ headers: { 'content-length': String(stat.size), etag: stat.etag, 'content-type': stat.type },
1172
+ });
1173
+ }
1174
+ const sliced = rangeTo === undefined ? f.slice(rangeFrom) : f.slice(rangeFrom, rangeTo);
1175
+ const buf = await sliced.arrayBuffer();
1176
+ const stat = await f.stat();
1177
+ const endByte = rangeTo === undefined ? stat.size - 1 : rangeTo - 1;
1178
+ return new Response(buf, {
1179
+ status: 206,
1180
+ headers: {
1181
+ 'content-range': `bytes ${rangeFrom}-${endByte}/${stat.size}`,
1182
+ 'content-length': String(buf.byteLength),
1183
+ },
1184
+ });
1185
+ }
1042
1186
  let rangeHdr = {};
1043
1187
  if (!wholeFile) {
1044
1188
  rangeHdr =
@@ -1059,6 +1203,9 @@ class S3mini {
1059
1203
  */
1060
1204
  async getContentLength(key, ssecHeaders) {
1061
1205
  try {
1206
+ if (this._bun && !ssecHeaders) {
1207
+ return (await this._bun.file(key).stat()).size;
1208
+ }
1062
1209
  const res = await this._signedRequest('HEAD', key, {
1063
1210
  headers: ssecHeaders ? { ...ssecHeaders } : undefined,
1064
1211
  });
@@ -1080,6 +1227,9 @@ class S3mini {
1080
1227
  * @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
1081
1228
  */
1082
1229
  async objectExists(key, opts = {}) {
1230
+ if (this._bun && !Object.keys(opts).length) {
1231
+ return this._bun.file(key).exists();
1232
+ }
1083
1233
  const res = await this._signedRequest('HEAD', key, {
1084
1234
  query: opts,
1085
1235
  tolerated: [200, 404, 412, 304],
@@ -1106,6 +1256,15 @@ class S3mini {
1106
1256
  * }
1107
1257
  */
1108
1258
  async getEtag(key, opts = {}, ssecHeaders) {
1259
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1260
+ return this._bunRead(key, async (f) => {
1261
+ const { etag } = await f.stat();
1262
+ if (!etag) {
1263
+ throw new Error(`${ERROR_PREFIX}ETag not found in response headers`);
1264
+ }
1265
+ return sanitizeETag(etag);
1266
+ });
1267
+ }
1109
1268
  const res = await this._signedRequest('HEAD', key, {
1110
1269
  query: opts,
1111
1270
  tolerated: [200, 304, 404, 412],
@@ -1141,6 +1300,12 @@ class S3mini {
1141
1300
  * await s3.putObject('image.png', buffer, 'image/png');
1142
1301
  */
1143
1302
  async putObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders, additionalHeaders, contentLength) {
1303
+ if (this._bun && !ssecHeaders && !additionalHeaders) {
1304
+ const f = this._bun.file(key);
1305
+ await f.write(data, { type: fileType });
1306
+ const { etag } = await f.stat();
1307
+ return new Response(null, { status: 200, headers: etag ? { etag } : {} });
1308
+ }
1144
1309
  const size = contentLength ?? getByteSize(data);
1145
1310
  return this._signedRequest('PUT', key, {
1146
1311
  body: data,
@@ -1173,6 +1338,14 @@ class S3mini {
1173
1338
  * await s3.putAnyObject('image.png', buffer, 'image/png');
1174
1339
  */
1175
1340
  async putAnyObject(key, data, fileType = DEFAULT_STREAM_CONTENT_TYPE, ssecHeaders, additionalHeaders, contentLength) {
1341
+ // Bun handles multipart automatically for large files
1342
+ if (this._bun && !ssecHeaders && !additionalHeaders) {
1343
+ this._checkKey(key);
1344
+ const f = this._bun.file(key);
1345
+ await f.write(data, { type: fileType });
1346
+ const { etag } = await f.stat();
1347
+ return this._createSuccessResponse(etag || '');
1348
+ }
1176
1349
  const size = contentLength ?? getByteSize(data);
1177
1350
  // Single PUT for small files
1178
1351
  if (!Number.isNaN(size) && size <= this.minPartSize) {
@@ -1635,72 +1808,74 @@ class S3mini {
1635
1808
  * @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
1636
1809
  */
1637
1810
  async deleteObject(key) {
1811
+ if (this._bun) {
1812
+ await this._bun.file(key).delete();
1813
+ return true;
1814
+ }
1638
1815
  const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
1639
1816
  return res.status === 200 || res.status === 204;
1640
1817
  }
1641
1818
  async _deleteObjectsProcess(keys) {
1819
+ const out = await this._sendDeleteRequest(keys);
1820
+ const resultMap = new Map(keys.map(k => [k, false]));
1821
+ this._markDeletedKeys(out, resultMap);
1822
+ this._logDeleteErrors(out, resultMap);
1823
+ return keys.map(key => resultMap.get(key) || false);
1824
+ }
1825
+ async _sendDeleteRequest(keys) {
1642
1826
  const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
1643
1827
  const xmlBody = '<Delete>' + objectsXml + '</Delete>';
1644
- const query = { delete: '' };
1645
1828
  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
1829
  const res = await this._signedRequest('POST', '', {
1652
- query,
1830
+ query: { delete: '' },
1653
1831
  body: xmlBody,
1654
- headers,
1832
+ headers: {
1833
+ [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
1834
+ [HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
1835
+ [HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
1836
+ },
1655
1837
  withQuery: true,
1656
1838
  });
1657
1839
  const parsed = parseXml(await res.text());
1658
1840
  if (!parsed || typeof parsed !== 'object') {
1659
1841
  throw new Error(`${ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
1660
1842
  }
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
- }
1843
+ return (parsed.DeleteResult || parsed.deleteResult || parsed);
1844
+ }
1845
+ _markDeletedKeys(out, resultMap) {
1666
1846
  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
- }
1847
+ if (!deleted) {
1848
+ return;
1849
+ }
1850
+ const items = Array.isArray(deleted) ? deleted : [deleted];
1851
+ for (const item of items) {
1852
+ if (item && typeof item === 'object') {
1853
+ const key = item.key || item.Key;
1854
+ if (key && typeof key === 'string') {
1855
+ resultMap.set(key, true);
1677
1856
  }
1678
1857
  }
1679
1858
  }
1680
- // Handle errors (check both cases)
1859
+ }
1860
+ _logDeleteErrors(out, resultMap) {
1681
1861
  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
- }
1862
+ if (!errors) {
1863
+ return;
1864
+ }
1865
+ const items = Array.isArray(errors) ? errors : [errors];
1866
+ for (const item of items) {
1867
+ if (item && typeof item === 'object') {
1868
+ const obj = item;
1869
+ const key = obj.key || obj.Key;
1870
+ if (key && typeof key === 'string') {
1871
+ resultMap.set(key, false);
1872
+ this._log('warn', `Failed to delete object: ${key}`, {
1873
+ code: obj.code || obj.Code || 'Unknown',
1874
+ message: obj.message || obj.Message || 'Unknown error',
1875
+ });
1699
1876
  }
1700
1877
  }
1701
1878
  }
1702
- // Return boolean array in the same order as input keys
1703
- return keys.map(key => resultMap.get(key) || false);
1704
1879
  }
1705
1880
  /**
1706
1881
  * Deletes multiple objects from the bucket.
@@ -1815,6 +1990,9 @@ class S3mini {
1815
1990
  if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
1816
1991
  throw new TypeError(`${ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
1817
1992
  }
1993
+ if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
1994
+ return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
1995
+ }
1818
1996
  return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
1819
1997
  }
1820
1998
  async _presign(method, keyPath, expiresIn, queryParams, headers) {