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/src/S3.ts CHANGED
@@ -17,6 +17,9 @@ import {
17
17
  S3ServiceError,
18
18
  generateParts,
19
19
  toUint8Array,
20
+ isBun,
21
+ extractBaseEndpoint,
22
+ byCodePoint,
20
23
  } from './utils.js';
21
24
  import type * as IT from './types.js';
22
25
 
@@ -70,6 +73,7 @@ class S3mini {
70
73
  readonly logger?: IT.Logger;
71
74
  readonly _fetch: typeof fetch;
72
75
  readonly minPartSize: number;
76
+ private readonly _bun?: IT.NativeS3Client;
73
77
  private signingKeyDate?: string;
74
78
  private signingKey?: ArrayBuffer;
75
79
 
@@ -95,6 +99,19 @@ class S3mini {
95
99
  this.logger = logger;
96
100
  this._fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => fetch(input, init);
97
101
  this.minPartSize = minPartSize;
102
+
103
+ if (isBun) {
104
+ const { S3Client } = (
105
+ globalThis as unknown as { Bun: { S3Client: new (o: Record<string, unknown>) => IT.NativeS3Client } }
106
+ ).Bun;
107
+ this._bun = new S3Client({
108
+ accessKeyId,
109
+ secretAccessKey,
110
+ endpoint: extractBaseEndpoint(this.endpoint, this.bucketName),
111
+ region: this.region,
112
+ bucket: this.bucketName,
113
+ });
114
+ }
98
115
  }
99
116
 
100
117
  private _sanitize(obj: unknown): unknown {
@@ -169,6 +186,18 @@ class S3mini {
169
186
  return this.#accessKeyId.trim().length > 0 && this.#secretAccessKey.trim().length > 0;
170
187
  }
171
188
 
189
+ /** Run a read op via Bun-native S3, returning null on NoSuchKey. */
190
+ private async _bunRead<T>(key: string, op: (f: IT.NativeS3File) => Promise<T>): Promise<T | null> {
191
+ try {
192
+ return await op(this._bun!.file(key));
193
+ } catch (e) {
194
+ if ((e as { code?: string })?.code === 'NoSuchKey') {
195
+ return null;
196
+ }
197
+ throw e;
198
+ }
199
+ }
200
+
172
201
  private _ensureValidUrl(raw: string): string {
173
202
  const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
174
203
  try {
@@ -317,20 +346,13 @@ class S3mini {
317
346
 
318
347
  const ignoredHeaders = new Set(['authorization', 'content-length', 'content-type', 'user-agent']);
319
348
 
320
- let canonicalHeaders = '';
321
- let signedHeaders = '';
349
+ const sortedHeaders = Object.entries(headers)
350
+ .map(([key, value]): [string, string] => [key.toLowerCase(), String(value).trim()])
351
+ .filter(([lowerKey]) => !ignoredHeaders.has(lowerKey))
352
+ .sort(([a], [b]) => byCodePoint(a, b));
322
353
 
323
- for (const [key, value] of Object.entries(headers).sort(([a], [b]) => a.localeCompare(b))) {
324
- const lowerKey = key.toLowerCase();
325
- if (!ignoredHeaders.has(lowerKey)) {
326
- if (canonicalHeaders) {
327
- canonicalHeaders += '\n';
328
- signedHeaders += ';';
329
- }
330
- canonicalHeaders += `${lowerKey}:${String(value).trim()}`;
331
- signedHeaders += lowerKey;
332
- }
333
- }
354
+ const canonicalHeaders = sortedHeaders.map(([k, v]) => `${k}:${v}`).join('\n');
355
+ const signedHeaders = sortedHeaders.map(([k]) => k).join(';');
334
356
  const canonicalRequest = `${method}\n${url.pathname}\n${this._buildCanonicalQueryString(query)}\n${canonicalHeaders}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
335
357
  const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
336
358
  if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
@@ -426,37 +448,29 @@ class S3mini {
426
448
  private _extractBucketName(): string {
427
449
  const url = this.endpoint;
428
450
 
429
- // First check if bucket is in the pathname (path-style URLs)
430
- const pathSegments = url.pathname.split('/').filter(Boolean);
431
- if (pathSegments.length > 0) {
432
- if (typeof pathSegments[0] === 'string') {
433
- return pathSegments[0];
434
- }
451
+ // Path-style: bucket is the first non-empty path segment
452
+ const firstSegment = url.pathname.split('/').find(Boolean);
453
+ if (firstSegment) {
454
+ return firstSegment;
435
455
  }
436
456
 
437
- // Otherwise extract from subdomain (virtual-hosted-style URLs)
438
- const hostParts = url.hostname.split('.');
457
+ // Virtual-hosted style: bucket is the first subdomain label
458
+ const hostname = url.hostname;
439
459
 
440
- // Common patterns:
441
- // bucket-name.s3.amazonaws.com
442
- // bucket-name.s3.region.amazonaws.com
443
- // bucket-name.region.digitaloceanspaces.com
444
- // bucket-name.region.cdn.digitaloceanspaces.com
460
+ // IP addresses (v4: digits+dots, v6: contains colons) can't carry a bucket subdomain
461
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
462
+ return '';
463
+ }
445
464
 
446
- if (hostParts.length >= 3) {
447
- // Check if it's a known S3-compatible service
448
- const domain = hostParts.slice(-2).join('.');
449
- const knownDomains = ['amazonaws.com', 'digitaloceanspaces.com', 'cloudflare.com'];
465
+ const labels = hostname.split('.');
450
466
 
451
- if (knownDomains.some(d => domain.includes(d))) {
452
- if (typeof hostParts[0] === 'string') {
453
- return hostParts[0];
454
- }
455
- }
467
+ // Need ≥3 labels for virtual-hosted (bucket.service.tld)
468
+ // Single-label (localhost) or two-label (example.com) have no room for a bucket subdomain
469
+ if (labels.length < 3) {
470
+ return '';
456
471
  }
457
472
 
458
- // Fallback: use the first subdomain
459
- return hostParts[0] || '';
473
+ return labels[0]!;
460
474
  }
461
475
 
462
476
  /**
@@ -494,6 +508,13 @@ class S3mini {
494
508
  this._checkPrefix(prefix);
495
509
  this._checkOpts(opts);
496
510
 
511
+ if (this._bun && delimiter === '/') {
512
+ const extraKeys = Object.keys(opts).filter(k => k !== 'delimiter');
513
+ if (extraKeys.length === 0) {
514
+ return this._bunListAll(prefix, maxKeys, opts.delimiter as string | undefined);
515
+ }
516
+ }
517
+
497
518
  const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
498
519
  const unlimited = !(maxKeys && maxKeys > 0);
499
520
  let remaining = unlimited ? Infinity : maxKeys;
@@ -698,6 +719,90 @@ class S3mini {
698
719
  response.nextMarker) as string | undefined;
699
720
  }
700
721
 
722
+ private async _bunListAll(
723
+ prefix: string,
724
+ maxKeys: number | undefined,
725
+ delimiter: string | undefined,
726
+ ): Promise<IT.ListObject[] | null> {
727
+ const unlimited = !(maxKeys && maxKeys > 0);
728
+ let remaining = unlimited ? Infinity : maxKeys;
729
+ let startAfter: string | undefined;
730
+ const all: IT.ListObject[] = [];
731
+
732
+ try {
733
+ do {
734
+ const batchSize = Math.min(remaining === Infinity ? 1000 : remaining, 1000);
735
+ const res = await this._bunFetchPage(prefix, delimiter, batchSize, startAfter);
736
+ const mapped = this._bunMapListResult(res);
737
+ all.push(...mapped);
738
+
739
+ if (!unlimited) {
740
+ remaining -= mapped.length;
741
+ }
742
+
743
+ startAfter = this._bunNextCursor(res);
744
+ } while (startAfter && remaining > 0);
745
+ } catch (e) {
746
+ if ((e as { code?: string })?.code === 'NoSuchBucket') {
747
+ return null;
748
+ }
749
+ throw e;
750
+ }
751
+
752
+ return all;
753
+ }
754
+
755
+ private _bunFetchPage(
756
+ prefix: string,
757
+ delimiter: string | undefined,
758
+ maxKeys: number,
759
+ startAfter?: string,
760
+ ): Promise<IT.NativeS3ListResult> {
761
+ return this._bun!.list({
762
+ prefix: prefix || undefined,
763
+ delimiter: delimiter || '/',
764
+ maxKeys,
765
+ ...(startAfter ? { startAfter } : {}),
766
+ });
767
+ }
768
+
769
+ private _bunNextCursor(res: IT.NativeS3ListResult): string | undefined {
770
+ if (!res.isTruncated) {
771
+ return undefined;
772
+ }
773
+ return res.contents?.length ? res.contents.at(-1)!.key : undefined;
774
+ }
775
+
776
+ private _bunMapListResult(res: IT.NativeS3ListResult): IT.ListObject[] {
777
+ const objects: IT.ListObject[] = [];
778
+
779
+ if (res.contents) {
780
+ for (const item of res.contents) {
781
+ objects.push({
782
+ Key: item.key,
783
+ Size: item.size,
784
+ LastModified: item.lastModified instanceof Date ? item.lastModified : new Date(item.lastModified),
785
+ ETag: item.etag ?? '',
786
+ StorageClass: item.storageClass ?? '',
787
+ });
788
+ }
789
+ }
790
+
791
+ if (res.commonPrefixes) {
792
+ for (const item of res.commonPrefixes) {
793
+ objects.push({
794
+ Key: item.prefix,
795
+ Size: 0,
796
+ LastModified: new Date(0),
797
+ ETag: '',
798
+ StorageClass: '',
799
+ } as IT.ListObject);
800
+ }
801
+ }
802
+
803
+ return objects;
804
+ }
805
+
701
806
  /**
702
807
  * Lists multipart uploads in the bucket.
703
808
  * This method sends a request to list multipart uploads in the specified bucket.
@@ -756,7 +861,9 @@ class S3mini {
756
861
  opts: Record<string, unknown> = {},
757
862
  ssecHeaders?: IT.SSECHeaders,
758
863
  ): Promise<string | null> {
759
- // if ssecHeaders is set, add it to headers
864
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
865
+ return this._bunRead(key, f => f.text());
866
+ }
760
867
  const res = await this._signedRequest('GET', key, {
761
868
  query: opts, // use opts.query if it exists, otherwise use an empty object
762
869
  tolerated: [200, 404, 412, 304],
@@ -808,6 +915,9 @@ class S3mini {
808
915
  opts: Record<string, unknown> = {},
809
916
  ssecHeaders?: IT.SSECHeaders,
810
917
  ): Promise<ArrayBuffer | null> {
918
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
919
+ return this._bunRead(key, f => f.arrayBuffer());
920
+ }
811
921
  const res = await this._signedRequest('GET', key, {
812
922
  query: opts,
813
923
  tolerated: [200, 404, 412, 304],
@@ -833,6 +943,9 @@ class S3mini {
833
943
  opts: Record<string, unknown> = {},
834
944
  ssecHeaders?: IT.SSECHeaders,
835
945
  ): Promise<T | null> {
946
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
947
+ return this._bunRead(key, f => f.json()) as Promise<T | null>;
948
+ }
836
949
  const res = await this._signedRequest('GET', key, {
837
950
  query: opts,
838
951
  tolerated: [200, 404, 412, 304],
@@ -858,6 +971,18 @@ class S3mini {
858
971
  opts: Record<string, unknown> = {},
859
972
  ssecHeaders?: IT.SSECHeaders,
860
973
  ): Promise<{ etag: string | null; data: ArrayBuffer | null }> {
974
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
975
+ try {
976
+ const f = this._bun.file(key);
977
+ const [stat, data] = await Promise.all([f.stat(), f.arrayBuffer()]);
978
+ return { etag: sanitizeETag(stat.etag), data };
979
+ } catch (e) {
980
+ if ((e as { code?: string })?.code === 'NoSuchKey') {
981
+ return { etag: null, data: null };
982
+ }
983
+ throw e;
984
+ }
985
+ }
861
986
  try {
862
987
  const res = await this._signedRequest('GET', key, {
863
988
  query: opts,
@@ -900,6 +1025,29 @@ class S3mini {
900
1025
  opts: Record<string, unknown> = {},
901
1026
  ssecHeaders?: IT.SSECHeaders,
902
1027
  ): Promise<Response> {
1028
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1029
+ const f = this._bun.file(key);
1030
+ if (wholeFile) {
1031
+ const buf = await f.arrayBuffer();
1032
+ const stat = await f.stat();
1033
+ return new Response(buf, {
1034
+ status: 200,
1035
+ headers: { 'content-length': String(stat.size), etag: stat.etag, 'content-type': stat.type },
1036
+ });
1037
+ }
1038
+ const sliced = rangeTo === undefined ? f.slice(rangeFrom) : f.slice(rangeFrom, rangeTo);
1039
+ const buf = await sliced.arrayBuffer();
1040
+ const stat = await f.stat();
1041
+ const endByte = rangeTo === undefined ? stat.size - 1 : rangeTo - 1;
1042
+ return new Response(buf, {
1043
+ status: 206,
1044
+ headers: {
1045
+ 'content-range': `bytes ${rangeFrom}-${endByte}/${stat.size}`,
1046
+ 'content-length': String(buf.byteLength),
1047
+ },
1048
+ });
1049
+ }
1050
+
903
1051
  let rangeHdr: Record<string, string | number> = {};
904
1052
 
905
1053
  if (!wholeFile) {
@@ -922,6 +1070,9 @@ class S3mini {
922
1070
  */
923
1071
  public async getContentLength(key: string, ssecHeaders?: IT.SSECHeaders): Promise<number> {
924
1072
  try {
1073
+ if (this._bun && !ssecHeaders) {
1074
+ return (await this._bun.file(key).stat()).size;
1075
+ }
925
1076
  const res = await this._signedRequest('HEAD', key, {
926
1077
  headers: ssecHeaders ? { ...ssecHeaders } : undefined,
927
1078
  });
@@ -943,6 +1094,9 @@ class S3mini {
943
1094
  * @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
944
1095
  */
945
1096
  public async objectExists(key: string, opts: Record<string, unknown> = {}): Promise<IT.ExistResponseCode> {
1097
+ if (this._bun && !Object.keys(opts).length) {
1098
+ return this._bun.file(key).exists();
1099
+ }
946
1100
  const res = await this._signedRequest('HEAD', key, {
947
1101
  query: opts,
948
1102
  tolerated: [200, 404, 412, 304],
@@ -975,6 +1129,15 @@ class S3mini {
975
1129
  opts: Record<string, unknown> = {},
976
1130
  ssecHeaders?: IT.SSECHeaders,
977
1131
  ): Promise<string | null> {
1132
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1133
+ return this._bunRead(key, async f => {
1134
+ const { etag } = await f.stat();
1135
+ if (!etag) {
1136
+ throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
1137
+ }
1138
+ return sanitizeETag(etag);
1139
+ });
1140
+ }
978
1141
  const res = await this._signedRequest('HEAD', key, {
979
1142
  query: opts,
980
1143
  tolerated: [200, 304, 404, 412],
@@ -1022,6 +1185,12 @@ class S3mini {
1022
1185
  additionalHeaders?: IT.AWSHeaders,
1023
1186
  contentLength?: number,
1024
1187
  ): Promise<Response> {
1188
+ if (this._bun && !ssecHeaders && !additionalHeaders) {
1189
+ const f = this._bun.file(key);
1190
+ await f.write(data as string | ArrayBuffer | Uint8Array | Blob | ReadableStream, { type: fileType });
1191
+ const { etag } = await f.stat();
1192
+ return new Response(null, { status: 200, headers: etag ? { etag } : {} });
1193
+ }
1025
1194
  const size = contentLength ?? getByteSize(data);
1026
1195
  return this._signedRequest('PUT', key, {
1027
1196
  body: data as BodyInit,
@@ -1062,6 +1231,15 @@ class S3mini {
1062
1231
  additionalHeaders?: IT.AWSHeaders,
1063
1232
  contentLength?: number,
1064
1233
  ): Promise<Response | { ok: boolean; status: number; headers: Map<string, string> }> {
1234
+ // Bun handles multipart automatically for large files
1235
+ if (this._bun && !ssecHeaders && !additionalHeaders) {
1236
+ this._checkKey(key);
1237
+ const f = this._bun.file(key);
1238
+ await f.write(data as string | ArrayBuffer | Uint8Array | Blob | ReadableStream, { type: fileType });
1239
+ const { etag } = await f.stat();
1240
+ return this._createSuccessResponse(etag || '');
1241
+ }
1242
+
1065
1243
  const size = contentLength ?? getByteSize(data);
1066
1244
 
1067
1245
  // Single PUT for small files
@@ -1697,77 +1875,84 @@ class S3mini {
1697
1875
  * @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
1698
1876
  */
1699
1877
  public async deleteObject(key: string): Promise<boolean> {
1878
+ if (this._bun) {
1879
+ await this._bun.file(key).delete();
1880
+ return true;
1881
+ }
1700
1882
  const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
1701
1883
  return res.status === 200 || res.status === 204;
1702
1884
  }
1703
1885
 
1704
1886
  private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> {
1887
+ const out = await this._sendDeleteRequest(keys);
1888
+
1889
+ const resultMap = new Map<string, boolean>(keys.map(k => [k, false]));
1890
+ this._markDeletedKeys(out, resultMap);
1891
+ this._logDeleteErrors(out, resultMap);
1892
+
1893
+ return keys.map(key => resultMap.get(key) || false);
1894
+ }
1895
+
1896
+ private async _sendDeleteRequest(keys: string[]): Promise<Record<string, unknown>> {
1705
1897
  const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
1706
1898
  const xmlBody = '<Delete>' + objectsXml + '</Delete>';
1707
- const query = { delete: '' };
1708
1899
  const sha256base64 = base64FromBuffer(await sha256(xmlBody));
1709
- const headers = {
1710
- [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
1711
- [C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
1712
- [C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
1713
- };
1714
1900
 
1715
1901
  const res = await this._signedRequest('POST', '', {
1716
- query,
1902
+ query: { delete: '' },
1717
1903
  body: xmlBody,
1718
- headers,
1904
+ headers: {
1905
+ [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
1906
+ [C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
1907
+ [C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
1908
+ },
1719
1909
  withQuery: true,
1720
1910
  });
1911
+
1721
1912
  const parsed = parseXml(await res.text()) as Record<string, unknown>;
1722
1913
  if (!parsed || typeof parsed !== 'object') {
1723
1914
  throw new Error(`${C.ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
1724
1915
  }
1725
- const out = (parsed.DeleteResult || parsed.deleteResult || parsed) as Record<string, unknown>;
1726
- const resultMap = new Map<string, boolean>();
1727
- for (const key of keys) {
1728
- resultMap.set(key, false);
1729
- }
1916
+ return (parsed.DeleteResult || parsed.deleteResult || parsed) as Record<string, unknown>;
1917
+ }
1918
+
1919
+ private _markDeletedKeys(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
1730
1920
  const deleted = out.deleted || out.Deleted;
1731
- if (deleted) {
1732
- const deletedArray = Array.isArray(deleted) ? deleted : [deleted];
1733
- for (const item of deletedArray) {
1734
- if (item && typeof item === 'object') {
1735
- const obj = item as Record<string, unknown>;
1736
- // Check both key and Key
1737
- const key = obj.key || obj.Key;
1738
- if (key && typeof key === 'string') {
1739
- resultMap.set(key, true);
1740
- }
1921
+ if (!deleted) {
1922
+ return;
1923
+ }
1924
+
1925
+ const items = Array.isArray(deleted) ? deleted : [deleted];
1926
+ for (const item of items) {
1927
+ if (item && typeof item === 'object') {
1928
+ const key = (item as Record<string, unknown>).key || (item as Record<string, unknown>).Key;
1929
+ if (key && typeof key === 'string') {
1930
+ resultMap.set(key, true);
1741
1931
  }
1742
1932
  }
1743
1933
  }
1934
+ }
1744
1935
 
1745
- // Handle errors (check both cases)
1936
+ private _logDeleteErrors(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
1746
1937
  const errors = out.error || out.Error;
1747
- if (errors) {
1748
- const errorsArray = Array.isArray(errors) ? errors : [errors];
1749
- for (const item of errorsArray) {
1750
- if (item && typeof item === 'object') {
1751
- const obj = item as Record<string, unknown>;
1752
- // Check both cases for all properties
1753
- const key = obj.key || obj.Key;
1754
- const code = obj.code || obj.Code;
1755
- const message = obj.message || obj.Message;
1756
-
1757
- if (key && typeof key === 'string') {
1758
- resultMap.set(key, false);
1759
- // Optionally log the error for debugging
1760
- this._log('warn', `Failed to delete object: ${key}`, {
1761
- code: code || 'Unknown',
1762
- message: message || 'Unknown error',
1763
- });
1764
- }
1938
+ if (!errors) {
1939
+ return;
1940
+ }
1941
+
1942
+ const items = Array.isArray(errors) ? errors : [errors];
1943
+ for (const item of items) {
1944
+ if (item && typeof item === 'object') {
1945
+ const obj = item as Record<string, unknown>;
1946
+ const key = obj.key || obj.Key;
1947
+ if (key && typeof key === 'string') {
1948
+ resultMap.set(key, false);
1949
+ this._log('warn', `Failed to delete object: ${key}`, {
1950
+ code: obj.code || obj.Code || 'Unknown',
1951
+ message: obj.message || obj.Message || 'Unknown error',
1952
+ });
1765
1953
  }
1766
1954
  }
1767
1955
  }
1768
-
1769
- // Return boolean array in the same order as input keys
1770
- return keys.map(key => resultMap.get(key) || false);
1771
1956
  }
1772
1957
 
1773
1958
  /**
@@ -1862,8 +2047,9 @@ class S3mini {
1862
2047
  return '';
1863
2048
  }
1864
2049
  return Object.keys(queryParams)
1865
- .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key] as string)}`)
1866
- .sort((a, b) => a.localeCompare(b))
2050
+ .map((key): [string, string] => [encodeURIComponent(key), encodeURIComponent(String(queryParams[key]))])
2051
+ .sort(([a], [b]) => byCodePoint(a, b))
2052
+ .map(([k, v]) => `${k}=${v}`)
1867
2053
  .join('&');
1868
2054
  }
1869
2055
  /**
@@ -1902,6 +2088,9 @@ class S3mini {
1902
2088
  if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
1903
2089
  throw new TypeError(`${C.ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
1904
2090
  }
2091
+ if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
2092
+ return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
2093
+ }
1905
2094
  return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
1906
2095
  }
1907
2096
 
@@ -1933,7 +2122,7 @@ class S3mini {
1933
2122
  headerEntries.push([lowerKey, String(value).trim()]);
1934
2123
  }
1935
2124
  }
1936
- headerEntries.sort(([a], [b]) => a.localeCompare(b));
2125
+ headerEntries.sort(([a], [b]) => byCodePoint(a, b));
1937
2126
 
1938
2127
  const canonicalHeaders = headerEntries.map(([k, v]) => `${k}:${v}`).join('\n');
1939
2128
  const signedHeaders = headerEntries.map(([k]) => k).join(';');
package/src/types.ts CHANGED
@@ -163,3 +163,57 @@ type MaybeBuffer = typeof globalThis extends { Buffer?: infer B }
163
163
  : BinaryData;
164
164
 
165
165
  export type DataInput = string | MaybeBuffer | ReadableStream | File | Blob;
166
+
167
+ // Bun-native S3 interfaces (zero-cost in non-Bun runtimes)
168
+ export interface NativeS3Stat {
169
+ size: number;
170
+ etag: string;
171
+ lastModified: Date;
172
+ type: string;
173
+ }
174
+
175
+ export interface NativeS3File {
176
+ text(): Promise<string>;
177
+ json(): Promise<unknown>;
178
+ arrayBuffer(): Promise<ArrayBuffer>;
179
+ bytes(): Promise<Uint8Array>;
180
+ stream(): ReadableStream;
181
+ slice(start?: number, end?: number): NativeS3File;
182
+ write(data: string | ArrayBuffer | Uint8Array | Blob | ReadableStream, opts?: { type?: string }): Promise<number>;
183
+ writer(opts?: { type?: string }): { write(data: unknown): void; flush(): Promise<void>; end(): Promise<void> };
184
+ delete(): Promise<void>;
185
+ unlink(): Promise<void>;
186
+ exists(): Promise<boolean>;
187
+ stat(): Promise<NativeS3Stat>;
188
+ presign(opts?: { method?: string; expiresIn?: number; acl?: string; type?: string }): string;
189
+ }
190
+
191
+ export interface NativeS3ListObject {
192
+ key: string;
193
+ lastModified: Date;
194
+ size: number;
195
+ etag: string;
196
+ storageClass?: string;
197
+ }
198
+
199
+ export interface NativeS3ListResult {
200
+ contents?: NativeS3ListObject[];
201
+ commonPrefixes?: { prefix: string }[];
202
+ isTruncated: boolean;
203
+ nextContinuationToken?: string;
204
+ }
205
+
206
+ export interface NativeS3Client {
207
+ file(key: string): NativeS3File;
208
+ write(
209
+ key: string,
210
+ data: string | ArrayBuffer | Uint8Array | Blob | ReadableStream,
211
+ opts?: Record<string, unknown>,
212
+ ): Promise<number>;
213
+ delete(key: string): Promise<void>;
214
+ exists(key: string): Promise<boolean>;
215
+ size(key: string): Promise<number>;
216
+ stat(key: string): Promise<NativeS3Stat>;
217
+ presign(key: string, opts?: { method?: string; expiresIn?: number; acl?: string; type?: string }): string;
218
+ list(opts?: Record<string, unknown> | null, credentials?: Record<string, unknown>): Promise<NativeS3ListResult>;
219
+ }
package/src/utils.ts CHANGED
@@ -3,6 +3,34 @@
3
3
  import type { DataInput, XmlValue, XmlMap, ListBucketResponse, ErrorWithCode, PartData } from './types.js';
4
4
  import { ERROR_PREFIX } from './consts.js';
5
5
 
6
+ export const isBun = typeof navigator !== 'undefined' && navigator.userAgent === 'Bun';
7
+
8
+ /** Strips the bucket name from a full endpoint URL, returning the base origin for Bun.S3Client. */
9
+ export const extractBaseEndpoint = (endpoint: URL, bucket: string): string => {
10
+ // Path-style (/bucket/…): just use the origin
11
+ if (endpoint.pathname.split('/').some(Boolean)) {
12
+ return endpoint.origin;
13
+ }
14
+ // Virtual-hosted (bucket.host…): strip the bucket subdomain
15
+ const prefix = bucket + '.';
16
+ if (endpoint.hostname.startsWith(prefix)) {
17
+ const base = endpoint.hostname.slice(prefix.length);
18
+ return `${endpoint.protocol}//${base}${endpoint.port ? ':' + endpoint.port : ''}`;
19
+ }
20
+ return endpoint.origin;
21
+ };
22
+
23
+ /**
24
+ * Compare two strings by code point, as required for AWS SigV4 canonical
25
+ * ordering of query parameters and headers. `localeCompare` MUST NOT be used
26
+ * here: it is locale-aware and case-insensitive by default, so it mis-orders
27
+ * mixed-case names (e.g. `partNumber` before `X-Amz-*`) and breaks signatures.
28
+ * @param a First string
29
+ * @param b Second string
30
+ * @returns -1, 0, or 1
31
+ */
32
+ export const byCodePoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
33
+
6
34
  const ENCODR = new TextEncoder();
7
35
  const chunkSize = 0x8000; // 32KB chunks
8
36
  const HEX_CHARS = new Uint8Array([48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102]);