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/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]);
@@ -155,7 +170,7 @@ const unescapeXml = (value) => value.replaceAll(/&(quot|apos|lt|gt|amp);/g, m =>
155
170
  */
156
171
  const parseXml = (input) => {
157
172
  const xmlContent = input.replace(/<\?xml[^?]*\?>\s*/, '');
158
- const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]*>([\s\S]*?)<\/\1>/gm;
173
+ const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]*?>([\s\S]*?)<\/\1>/gm;
159
174
  const result = {}; // strong type, no `any`
160
175
  let match;
161
176
  while ((match = RE_TAG.exec(xmlContent)) !== null) {
@@ -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;
@@ -772,13 +808,17 @@ class S3mini {
772
808
  this._checkOpts(opts);
773
809
  const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
774
810
  let token = nextContinuationToken;
811
+ let remaining = maxKeys;
775
812
  const all = [];
776
- const batchResult = await this._fetchObjectBatch(keyPath, prefix, maxKeys, token, opts);
777
- if (batchResult === null) {
778
- return null; // 404 - bucket not found
779
- }
780
- all.push(...batchResult.objects);
781
- token = batchResult.continuationToken;
813
+ do {
814
+ const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
815
+ if (batchResult === null) {
816
+ return null; // 404 - bucket not found
817
+ }
818
+ all.push(...batchResult.objects);
819
+ remaining -= batchResult.objects.length;
820
+ token = batchResult.continuationToken;
821
+ } while (token && remaining > 0);
782
822
  return { objects: all, nextContinuationToken: token };
783
823
  }
784
824
  async _fetchObjectBatch(keyPath, prefix, remaining, token, opts) {
@@ -873,6 +913,71 @@ class S3mini {
873
913
  response.NextMarker ||
874
914
  response.nextMarker);
875
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
+ }
876
981
  /**
877
982
  * Lists multipart uploads in the bucket.
878
983
  * This method sends a request to list multipart uploads in the specified bucket.
@@ -919,7 +1024,9 @@ class S3mini {
919
1024
  * @returns A promise that resolves to the object data (string) or null if not found.
920
1025
  */
921
1026
  async getObject(key, opts = {}, ssecHeaders) {
922
- // 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
+ }
923
1030
  const res = await this._signedRequest('GET', key, {
924
1031
  query: opts, // use opts.query if it exists, otherwise use an empty object
925
1032
  tolerated: [200, 404, 412, 304],
@@ -961,6 +1068,9 @@ class S3mini {
961
1068
  * @returns A promise that resolves to the object data as an ArrayBuffer or null if not found.
962
1069
  */
963
1070
  async getObjectArrayBuffer(key, opts = {}, ssecHeaders) {
1071
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1072
+ return this._bunRead(key, f => f.arrayBuffer());
1073
+ }
964
1074
  const res = await this._signedRequest('GET', key, {
965
1075
  query: opts,
966
1076
  tolerated: [200, 404, 412, 304],
@@ -981,6 +1091,9 @@ class S3mini {
981
1091
  * @returns A promise that resolves to the object data as JSON or null if not found.
982
1092
  */
983
1093
  async getObjectJSON(key, opts = {}, ssecHeaders) {
1094
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1095
+ return this._bunRead(key, f => f.json());
1096
+ }
984
1097
  const res = await this._signedRequest('GET', key, {
985
1098
  query: opts,
986
1099
  tolerated: [200, 404, 412, 304],
@@ -1001,6 +1114,19 @@ class S3mini {
1001
1114
  * @returns A promise that resolves to an object containing the ETag and the object data as an ArrayBuffer or null if not found.
1002
1115
  */
1003
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
+ }
1004
1130
  try {
1005
1131
  const res = await this._signedRequest('GET', key, {
1006
1132
  query: opts,
@@ -1035,6 +1161,28 @@ class S3mini {
1035
1161
  * @returns A promise that resolves to the Response object.
1036
1162
  */
1037
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
+ }
1038
1186
  let rangeHdr = {};
1039
1187
  if (!wholeFile) {
1040
1188
  rangeHdr =
@@ -1055,6 +1203,9 @@ class S3mini {
1055
1203
  */
1056
1204
  async getContentLength(key, ssecHeaders) {
1057
1205
  try {
1206
+ if (this._bun && !ssecHeaders) {
1207
+ return (await this._bun.file(key).stat()).size;
1208
+ }
1058
1209
  const res = await this._signedRequest('HEAD', key, {
1059
1210
  headers: ssecHeaders ? { ...ssecHeaders } : undefined,
1060
1211
  });
@@ -1076,6 +1227,9 @@ class S3mini {
1076
1227
  * @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
1077
1228
  */
1078
1229
  async objectExists(key, opts = {}) {
1230
+ if (this._bun && !Object.keys(opts).length) {
1231
+ return this._bun.file(key).exists();
1232
+ }
1079
1233
  const res = await this._signedRequest('HEAD', key, {
1080
1234
  query: opts,
1081
1235
  tolerated: [200, 404, 412, 304],
@@ -1102,6 +1256,15 @@ class S3mini {
1102
1256
  * }
1103
1257
  */
1104
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
+ }
1105
1268
  const res = await this._signedRequest('HEAD', key, {
1106
1269
  query: opts,
1107
1270
  tolerated: [200, 304, 404, 412],
@@ -1137,6 +1300,12 @@ class S3mini {
1137
1300
  * await s3.putObject('image.png', buffer, 'image/png');
1138
1301
  */
1139
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
+ }
1140
1309
  const size = contentLength ?? getByteSize(data);
1141
1310
  return this._signedRequest('PUT', key, {
1142
1311
  body: data,
@@ -1169,6 +1338,14 @@ class S3mini {
1169
1338
  * await s3.putAnyObject('image.png', buffer, 'image/png');
1170
1339
  */
1171
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
+ }
1172
1349
  const size = contentLength ?? getByteSize(data);
1173
1350
  // Single PUT for small files
1174
1351
  if (!Number.isNaN(size) && size <= this.minPartSize) {
@@ -1631,72 +1808,74 @@ class S3mini {
1631
1808
  * @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
1632
1809
  */
1633
1810
  async deleteObject(key) {
1811
+ if (this._bun) {
1812
+ await this._bun.file(key).delete();
1813
+ return true;
1814
+ }
1634
1815
  const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
1635
1816
  return res.status === 200 || res.status === 204;
1636
1817
  }
1637
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) {
1638
1826
  const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
1639
1827
  const xmlBody = '<Delete>' + objectsXml + '</Delete>';
1640
- const query = { delete: '' };
1641
1828
  const sha256base64 = base64FromBuffer(await sha256(xmlBody));
1642
- const headers = {
1643
- [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
1644
- [HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
1645
- [HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
1646
- };
1647
1829
  const res = await this._signedRequest('POST', '', {
1648
- query,
1830
+ query: { delete: '' },
1649
1831
  body: xmlBody,
1650
- headers,
1832
+ headers: {
1833
+ [HEADER_CONTENT_TYPE]: XML_CONTENT_TYPE,
1834
+ [HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
1835
+ [HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
1836
+ },
1651
1837
  withQuery: true,
1652
1838
  });
1653
1839
  const parsed = parseXml(await res.text());
1654
1840
  if (!parsed || typeof parsed !== 'object') {
1655
1841
  throw new Error(`${ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
1656
1842
  }
1657
- const out = (parsed.DeleteResult || parsed.deleteResult || parsed);
1658
- const resultMap = new Map();
1659
- for (const key of keys) {
1660
- resultMap.set(key, false);
1661
- }
1843
+ return (parsed.DeleteResult || parsed.deleteResult || parsed);
1844
+ }
1845
+ _markDeletedKeys(out, resultMap) {
1662
1846
  const deleted = out.deleted || out.Deleted;
1663
- if (deleted) {
1664
- const deletedArray = Array.isArray(deleted) ? deleted : [deleted];
1665
- for (const item of deletedArray) {
1666
- if (item && typeof item === 'object') {
1667
- const obj = item;
1668
- // Check both key and Key
1669
- const key = obj.key || obj.Key;
1670
- if (key && typeof key === 'string') {
1671
- resultMap.set(key, true);
1672
- }
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);
1673
1856
  }
1674
1857
  }
1675
1858
  }
1676
- // Handle errors (check both cases)
1859
+ }
1860
+ _logDeleteErrors(out, resultMap) {
1677
1861
  const errors = out.error || out.Error;
1678
- if (errors) {
1679
- const errorsArray = Array.isArray(errors) ? errors : [errors];
1680
- for (const item of errorsArray) {
1681
- if (item && typeof item === 'object') {
1682
- const obj = item;
1683
- // Check both cases for all properties
1684
- const key = obj.key || obj.Key;
1685
- const code = obj.code || obj.Code;
1686
- const message = obj.message || obj.Message;
1687
- if (key && typeof key === 'string') {
1688
- resultMap.set(key, false);
1689
- // Optionally log the error for debugging
1690
- this._log('warn', `Failed to delete object: ${key}`, {
1691
- code: code || 'Unknown',
1692
- message: message || 'Unknown error',
1693
- });
1694
- }
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
+ });
1695
1876
  }
1696
1877
  }
1697
1878
  }
1698
- // Return boolean array in the same order as input keys
1699
- return keys.map(key => resultMap.get(key) || false);
1700
1879
  }
1701
1880
  /**
1702
1881
  * Deletes multiple objects from the bucket.
@@ -1789,27 +1968,34 @@ class S3mini {
1789
1968
  * @param {'GET' | 'PUT'} method - HTTP method ('GET' for download, 'PUT' for upload)
1790
1969
  * @param {string} key - The object key/path
1791
1970
  * @param {number} [expiresIn=3600] - URL expiration time in seconds (1–604800)
1792
- * @param {Record<string, string>} [queryParams={}] - Additional query parameters to sign
1971
+ * @param {Record<string, string>} [queryParams={}] - Additional query parameters to include in the URL
1972
+ * @param {Record<string, string>} [headers={}] - HTTP headers to sign. The consumer of the URL
1973
+ * MUST send these exact headers with matching values. The `host` header is always signed automatically.
1793
1974
  * @returns {Promise<string>} Pre-signed URL string
1794
1975
  * @throws {TypeError} If key is empty or expiresIn is out of range
1795
1976
  * @example
1796
1977
  * // Download URL valid for 1 hour
1797
1978
  * const url = await s3.getPresignedUrl('GET', 'photos/vacation.jpg');
1798
1979
  *
1799
- * // Upload URL valid for 5 minutes
1800
- * const url = await s3.getPresignedUrl('PUT', 'uploads/file.bin', 300);
1980
+ * // Upload URL valid for 5 minutes with signed Content-Type
1981
+ * const url = await s3.getPresignedUrl('PUT', 'uploads/file.bin', 300, {}, {
1982
+ * 'Content-Type': 'application/octet-stream',
1983
+ * });
1801
1984
  *
1802
- * // Client-side usage (no credentials needed)
1803
- * await fetch(url, { method: 'PUT', body: data });
1985
+ * // Client-side usage (must include signed headers)
1986
+ * await fetch(url, { method: 'PUT', body: data, headers: { 'Content-Type': 'application/octet-stream' } });
1804
1987
  */
1805
- async getPresignedUrl(method, key, expiresIn = 3600, queryParams = {}) {
1988
+ async getPresignedUrl(method, key, expiresIn = 3600, queryParams = {}, headers = {}) {
1806
1989
  this._checkKey(key);
1807
1990
  if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
1808
1991
  throw new TypeError(`${ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
1809
1992
  }
1810
- return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams);
1993
+ if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
1994
+ return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
1995
+ }
1996
+ return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
1811
1997
  }
1812
- async _presign(method, keyPath, expiresIn, queryParams) {
1998
+ async _presign(method, keyPath, expiresIn, queryParams, headers) {
1813
1999
  const url = new URL(this.endpoint);
1814
2000
  if (keyPath.length > 0) {
1815
2001
  url.pathname =
@@ -1822,7 +2008,16 @@ class S3mini {
1822
2008
  const shortDatetime = `${year}${month}${day}`;
1823
2009
  const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
1824
2010
  const credentialScope = `${shortDatetime}/${this.region}/${S3_SERVICE}/${AWS_REQUEST_TYPE}`;
1825
- const signedHeaders = 'host';
2011
+ const headerEntries = [['host', url.host]];
2012
+ for (const [key, value] of Object.entries(headers)) {
2013
+ const lowerKey = key.toLowerCase();
2014
+ if (lowerKey !== 'host') {
2015
+ headerEntries.push([lowerKey, String(value).trim()]);
2016
+ }
2017
+ }
2018
+ headerEntries.sort(([a], [b]) => a.localeCompare(b));
2019
+ const canonicalHeaders = headerEntries.map(([k, v]) => `${k}:${v}`).join('\n');
2020
+ const signedHeaders = headerEntries.map(([k]) => k).join(';');
1826
2021
  const allQueryParams = {
1827
2022
  ...queryParams,
1828
2023
  'X-Amz-Algorithm': AWS_ALGORITHM,
@@ -1832,7 +2027,7 @@ class S3mini {
1832
2027
  'X-Amz-SignedHeaders': signedHeaders,
1833
2028
  };
1834
2029
  const canonicalQueryString = this._buildCanonicalQueryString(allQueryParams);
1835
- const canonicalRequest = `${method}\n${url.pathname}\n${canonicalQueryString}\nhost:${url.host}\n\n${signedHeaders}\n${UNSIGNED_PAYLOAD}`;
2030
+ const canonicalRequest = `${method}\n${url.pathname}\n${canonicalQueryString}\n${canonicalHeaders}\n\n${signedHeaders}\n${UNSIGNED_PAYLOAD}`;
1836
2031
  const stringToSign = `${AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
1837
2032
  if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
1838
2033
  this.signingKeyDate = shortDatetime;