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/src/S3.ts CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  S3ServiceError,
18
18
  generateParts,
19
19
  toUint8Array,
20
+ isBun,
21
+ extractBaseEndpoint,
20
22
  } from './utils.js';
21
23
  import type * as IT from './types.js';
22
24
 
@@ -70,6 +72,7 @@ class S3mini {
70
72
  readonly logger?: IT.Logger;
71
73
  readonly _fetch: typeof fetch;
72
74
  readonly minPartSize: number;
75
+ private readonly _bun?: IT.NativeS3Client;
73
76
  private signingKeyDate?: string;
74
77
  private signingKey?: ArrayBuffer;
75
78
 
@@ -95,6 +98,19 @@ class S3mini {
95
98
  this.logger = logger;
96
99
  this._fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => fetch(input, init);
97
100
  this.minPartSize = minPartSize;
101
+
102
+ if (isBun) {
103
+ const { S3Client } = (
104
+ globalThis as unknown as { Bun: { S3Client: new (o: Record<string, unknown>) => IT.NativeS3Client } }
105
+ ).Bun;
106
+ this._bun = new S3Client({
107
+ accessKeyId,
108
+ secretAccessKey,
109
+ endpoint: extractBaseEndpoint(this.endpoint, this.bucketName),
110
+ region: this.region,
111
+ bucket: this.bucketName,
112
+ });
113
+ }
98
114
  }
99
115
 
100
116
  private _sanitize(obj: unknown): unknown {
@@ -169,6 +185,18 @@ class S3mini {
169
185
  return this.#accessKeyId.trim().length > 0 && this.#secretAccessKey.trim().length > 0;
170
186
  }
171
187
 
188
+ /** Run a read op via Bun-native S3, returning null on NoSuchKey. */
189
+ private async _bunRead<T>(key: string, op: (f: IT.NativeS3File) => Promise<T>): Promise<T | null> {
190
+ try {
191
+ return await op(this._bun!.file(key));
192
+ } catch (e) {
193
+ if ((e as { code?: string })?.code === 'NoSuchKey') {
194
+ return null;
195
+ }
196
+ throw e;
197
+ }
198
+ }
199
+
172
200
  private _ensureValidUrl(raw: string): string {
173
201
  const candidate = /^(https?:)?\/\//i.test(raw) ? raw : `https://${raw}`;
174
202
  try {
@@ -426,37 +454,29 @@ class S3mini {
426
454
  private _extractBucketName(): string {
427
455
  const url = this.endpoint;
428
456
 
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
- }
457
+ // Path-style: bucket is the first non-empty path segment
458
+ const firstSegment = url.pathname.split('/').find(Boolean);
459
+ if (firstSegment) {
460
+ return firstSegment;
435
461
  }
436
462
 
437
- // Otherwise extract from subdomain (virtual-hosted-style URLs)
438
- const hostParts = url.hostname.split('.');
463
+ // Virtual-hosted style: bucket is the first subdomain label
464
+ const hostname = url.hostname;
439
465
 
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
466
+ // IP addresses (v4: digits+dots, v6: contains colons) can't carry a bucket subdomain
467
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.includes(':')) {
468
+ return '';
469
+ }
445
470
 
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'];
471
+ const labels = hostname.split('.');
450
472
 
451
- if (knownDomains.some(d => domain.includes(d))) {
452
- if (typeof hostParts[0] === 'string') {
453
- return hostParts[0];
454
- }
455
- }
473
+ // Need ≥3 labels for virtual-hosted (bucket.service.tld)
474
+ // Single-label (localhost) or two-label (example.com) have no room for a bucket subdomain
475
+ if (labels.length < 3) {
476
+ return '';
456
477
  }
457
478
 
458
- // Fallback: use the first subdomain
459
- return hostParts[0] || '';
479
+ return labels[0]!;
460
480
  }
461
481
 
462
482
  /**
@@ -494,6 +514,13 @@ class S3mini {
494
514
  this._checkPrefix(prefix);
495
515
  this._checkOpts(opts);
496
516
 
517
+ if (this._bun && delimiter === '/') {
518
+ const extraKeys = Object.keys(opts).filter(k => k !== 'delimiter');
519
+ if (extraKeys.length === 0) {
520
+ return this._bunListAll(prefix, maxKeys, opts.delimiter as string | undefined);
521
+ }
522
+ }
523
+
497
524
  const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
498
525
  const unlimited = !(maxKeys && maxKeys > 0);
499
526
  let remaining = unlimited ? Infinity : maxKeys;
@@ -548,15 +575,20 @@ class S3mini {
548
575
 
549
576
  const keyPath = delimiter === '/' ? delimiter : uriEscape(delimiter);
550
577
  let token: string | undefined = nextContinuationToken;
578
+ let remaining = maxKeys;
551
579
  const all: IT.ListObject[] = [];
552
580
 
553
- const batchResult = await this._fetchObjectBatch(keyPath, prefix, maxKeys, token, opts);
554
- if (batchResult === null) {
555
- return null; // 404 - bucket not found
556
- }
581
+ do {
582
+ const batchResult = await this._fetchObjectBatch(keyPath, prefix, remaining, token, opts);
583
+ if (batchResult === null) {
584
+ return null; // 404 - bucket not found
585
+ }
586
+
587
+ all.push(...batchResult.objects);
588
+ remaining -= batchResult.objects.length;
589
+ token = batchResult.continuationToken;
590
+ } while (token && remaining > 0);
557
591
 
558
- all.push(...batchResult.objects);
559
- token = batchResult.continuationToken;
560
592
  return { objects: all, nextContinuationToken: token };
561
593
  }
562
594
 
@@ -693,6 +725,90 @@ class S3mini {
693
725
  response.nextMarker) as string | undefined;
694
726
  }
695
727
 
728
+ private async _bunListAll(
729
+ prefix: string,
730
+ maxKeys: number | undefined,
731
+ delimiter: string | undefined,
732
+ ): Promise<IT.ListObject[] | null> {
733
+ const unlimited = !(maxKeys && maxKeys > 0);
734
+ let remaining = unlimited ? Infinity : maxKeys;
735
+ let startAfter: string | undefined;
736
+ const all: IT.ListObject[] = [];
737
+
738
+ try {
739
+ do {
740
+ const batchSize = Math.min(remaining === Infinity ? 1000 : remaining, 1000);
741
+ const res = await this._bunFetchPage(prefix, delimiter, batchSize, startAfter);
742
+ const mapped = this._bunMapListResult(res);
743
+ all.push(...mapped);
744
+
745
+ if (!unlimited) {
746
+ remaining -= mapped.length;
747
+ }
748
+
749
+ startAfter = this._bunNextCursor(res);
750
+ } while (startAfter && remaining > 0);
751
+ } catch (e) {
752
+ if ((e as { code?: string })?.code === 'NoSuchBucket') {
753
+ return null;
754
+ }
755
+ throw e;
756
+ }
757
+
758
+ return all;
759
+ }
760
+
761
+ private _bunFetchPage(
762
+ prefix: string,
763
+ delimiter: string | undefined,
764
+ maxKeys: number,
765
+ startAfter?: string,
766
+ ): Promise<IT.NativeS3ListResult> {
767
+ return this._bun!.list({
768
+ prefix: prefix || undefined,
769
+ delimiter: delimiter || '/',
770
+ maxKeys,
771
+ ...(startAfter ? { startAfter } : {}),
772
+ });
773
+ }
774
+
775
+ private _bunNextCursor(res: IT.NativeS3ListResult): string | undefined {
776
+ if (!res.isTruncated) {
777
+ return undefined;
778
+ }
779
+ return res.contents?.length ? res.contents.at(-1)!.key : undefined;
780
+ }
781
+
782
+ private _bunMapListResult(res: IT.NativeS3ListResult): IT.ListObject[] {
783
+ const objects: IT.ListObject[] = [];
784
+
785
+ if (res.contents) {
786
+ for (const item of res.contents) {
787
+ objects.push({
788
+ Key: item.key,
789
+ Size: item.size,
790
+ LastModified: item.lastModified instanceof Date ? item.lastModified : new Date(item.lastModified),
791
+ ETag: item.etag ?? '',
792
+ StorageClass: item.storageClass ?? '',
793
+ });
794
+ }
795
+ }
796
+
797
+ if (res.commonPrefixes) {
798
+ for (const item of res.commonPrefixes) {
799
+ objects.push({
800
+ Key: item.prefix,
801
+ Size: 0,
802
+ LastModified: new Date(0),
803
+ ETag: '',
804
+ StorageClass: '',
805
+ } as IT.ListObject);
806
+ }
807
+ }
808
+
809
+ return objects;
810
+ }
811
+
696
812
  /**
697
813
  * Lists multipart uploads in the bucket.
698
814
  * This method sends a request to list multipart uploads in the specified bucket.
@@ -751,7 +867,9 @@ class S3mini {
751
867
  opts: Record<string, unknown> = {},
752
868
  ssecHeaders?: IT.SSECHeaders,
753
869
  ): Promise<string | null> {
754
- // if ssecHeaders is set, add it to headers
870
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
871
+ return this._bunRead(key, f => f.text());
872
+ }
755
873
  const res = await this._signedRequest('GET', key, {
756
874
  query: opts, // use opts.query if it exists, otherwise use an empty object
757
875
  tolerated: [200, 404, 412, 304],
@@ -803,6 +921,9 @@ class S3mini {
803
921
  opts: Record<string, unknown> = {},
804
922
  ssecHeaders?: IT.SSECHeaders,
805
923
  ): Promise<ArrayBuffer | null> {
924
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
925
+ return this._bunRead(key, f => f.arrayBuffer());
926
+ }
806
927
  const res = await this._signedRequest('GET', key, {
807
928
  query: opts,
808
929
  tolerated: [200, 404, 412, 304],
@@ -828,6 +949,9 @@ class S3mini {
828
949
  opts: Record<string, unknown> = {},
829
950
  ssecHeaders?: IT.SSECHeaders,
830
951
  ): Promise<T | null> {
952
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
953
+ return this._bunRead(key, f => f.json()) as Promise<T | null>;
954
+ }
831
955
  const res = await this._signedRequest('GET', key, {
832
956
  query: opts,
833
957
  tolerated: [200, 404, 412, 304],
@@ -853,6 +977,18 @@ class S3mini {
853
977
  opts: Record<string, unknown> = {},
854
978
  ssecHeaders?: IT.SSECHeaders,
855
979
  ): Promise<{ etag: string | null; data: ArrayBuffer | null }> {
980
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
981
+ try {
982
+ const f = this._bun.file(key);
983
+ const [stat, data] = await Promise.all([f.stat(), f.arrayBuffer()]);
984
+ return { etag: sanitizeETag(stat.etag), data };
985
+ } catch (e) {
986
+ if ((e as { code?: string })?.code === 'NoSuchKey') {
987
+ return { etag: null, data: null };
988
+ }
989
+ throw e;
990
+ }
991
+ }
856
992
  try {
857
993
  const res = await this._signedRequest('GET', key, {
858
994
  query: opts,
@@ -895,6 +1031,29 @@ class S3mini {
895
1031
  opts: Record<string, unknown> = {},
896
1032
  ssecHeaders?: IT.SSECHeaders,
897
1033
  ): Promise<Response> {
1034
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1035
+ const f = this._bun.file(key);
1036
+ if (wholeFile) {
1037
+ const buf = await f.arrayBuffer();
1038
+ const stat = await f.stat();
1039
+ return new Response(buf, {
1040
+ status: 200,
1041
+ headers: { 'content-length': String(stat.size), etag: stat.etag, 'content-type': stat.type },
1042
+ });
1043
+ }
1044
+ const sliced = rangeTo === undefined ? f.slice(rangeFrom) : f.slice(rangeFrom, rangeTo);
1045
+ const buf = await sliced.arrayBuffer();
1046
+ const stat = await f.stat();
1047
+ const endByte = rangeTo === undefined ? stat.size - 1 : rangeTo - 1;
1048
+ return new Response(buf, {
1049
+ status: 206,
1050
+ headers: {
1051
+ 'content-range': `bytes ${rangeFrom}-${endByte}/${stat.size}`,
1052
+ 'content-length': String(buf.byteLength),
1053
+ },
1054
+ });
1055
+ }
1056
+
898
1057
  let rangeHdr: Record<string, string | number> = {};
899
1058
 
900
1059
  if (!wholeFile) {
@@ -917,6 +1076,9 @@ class S3mini {
917
1076
  */
918
1077
  public async getContentLength(key: string, ssecHeaders?: IT.SSECHeaders): Promise<number> {
919
1078
  try {
1079
+ if (this._bun && !ssecHeaders) {
1080
+ return (await this._bun.file(key).stat()).size;
1081
+ }
920
1082
  const res = await this._signedRequest('HEAD', key, {
921
1083
  headers: ssecHeaders ? { ...ssecHeaders } : undefined,
922
1084
  });
@@ -938,6 +1100,9 @@ class S3mini {
938
1100
  * @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
939
1101
  */
940
1102
  public async objectExists(key: string, opts: Record<string, unknown> = {}): Promise<IT.ExistResponseCode> {
1103
+ if (this._bun && !Object.keys(opts).length) {
1104
+ return this._bun.file(key).exists();
1105
+ }
941
1106
  const res = await this._signedRequest('HEAD', key, {
942
1107
  query: opts,
943
1108
  tolerated: [200, 404, 412, 304],
@@ -970,6 +1135,15 @@ class S3mini {
970
1135
  opts: Record<string, unknown> = {},
971
1136
  ssecHeaders?: IT.SSECHeaders,
972
1137
  ): Promise<string | null> {
1138
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
1139
+ return this._bunRead(key, async f => {
1140
+ const { etag } = await f.stat();
1141
+ if (!etag) {
1142
+ throw new Error(`${C.ERROR_PREFIX}ETag not found in response headers`);
1143
+ }
1144
+ return sanitizeETag(etag);
1145
+ });
1146
+ }
973
1147
  const res = await this._signedRequest('HEAD', key, {
974
1148
  query: opts,
975
1149
  tolerated: [200, 304, 404, 412],
@@ -1017,6 +1191,12 @@ class S3mini {
1017
1191
  additionalHeaders?: IT.AWSHeaders,
1018
1192
  contentLength?: number,
1019
1193
  ): Promise<Response> {
1194
+ if (this._bun && !ssecHeaders && !additionalHeaders) {
1195
+ const f = this._bun.file(key);
1196
+ await f.write(data as string | ArrayBuffer | Uint8Array | Blob | ReadableStream, { type: fileType });
1197
+ const { etag } = await f.stat();
1198
+ return new Response(null, { status: 200, headers: etag ? { etag } : {} });
1199
+ }
1020
1200
  const size = contentLength ?? getByteSize(data);
1021
1201
  return this._signedRequest('PUT', key, {
1022
1202
  body: data as BodyInit,
@@ -1057,6 +1237,15 @@ class S3mini {
1057
1237
  additionalHeaders?: IT.AWSHeaders,
1058
1238
  contentLength?: number,
1059
1239
  ): Promise<Response | { ok: boolean; status: number; headers: Map<string, string> }> {
1240
+ // Bun handles multipart automatically for large files
1241
+ if (this._bun && !ssecHeaders && !additionalHeaders) {
1242
+ this._checkKey(key);
1243
+ const f = this._bun.file(key);
1244
+ await f.write(data as string | ArrayBuffer | Uint8Array | Blob | ReadableStream, { type: fileType });
1245
+ const { etag } = await f.stat();
1246
+ return this._createSuccessResponse(etag || '');
1247
+ }
1248
+
1060
1249
  const size = contentLength ?? getByteSize(data);
1061
1250
 
1062
1251
  // Single PUT for small files
@@ -1692,77 +1881,84 @@ class S3mini {
1692
1881
  * @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
1693
1882
  */
1694
1883
  public async deleteObject(key: string): Promise<boolean> {
1884
+ if (this._bun) {
1885
+ await this._bun.file(key).delete();
1886
+ return true;
1887
+ }
1695
1888
  const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
1696
1889
  return res.status === 200 || res.status === 204;
1697
1890
  }
1698
1891
 
1699
1892
  private async _deleteObjectsProcess(keys: string[]): Promise<boolean[]> {
1893
+ const out = await this._sendDeleteRequest(keys);
1894
+
1895
+ const resultMap = new Map<string, boolean>(keys.map(k => [k, false]));
1896
+ this._markDeletedKeys(out, resultMap);
1897
+ this._logDeleteErrors(out, resultMap);
1898
+
1899
+ return keys.map(key => resultMap.get(key) || false);
1900
+ }
1901
+
1902
+ private async _sendDeleteRequest(keys: string[]): Promise<Record<string, unknown>> {
1700
1903
  const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
1701
1904
  const xmlBody = '<Delete>' + objectsXml + '</Delete>';
1702
- const query = { delete: '' };
1703
1905
  const sha256base64 = base64FromBuffer(await sha256(xmlBody));
1704
- const headers = {
1705
- [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
1706
- [C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
1707
- [C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
1708
- };
1709
1906
 
1710
1907
  const res = await this._signedRequest('POST', '', {
1711
- query,
1908
+ query: { delete: '' },
1712
1909
  body: xmlBody,
1713
- headers,
1910
+ headers: {
1911
+ [C.HEADER_CONTENT_TYPE]: C.XML_CONTENT_TYPE,
1912
+ [C.HEADER_CONTENT_LENGTH]: getByteSize(xmlBody),
1913
+ [C.HEADER_AMZ_CHECKSUM_SHA256]: sha256base64,
1914
+ },
1714
1915
  withQuery: true,
1715
1916
  });
1917
+
1716
1918
  const parsed = parseXml(await res.text()) as Record<string, unknown>;
1717
1919
  if (!parsed || typeof parsed !== 'object') {
1718
1920
  throw new Error(`${C.ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
1719
1921
  }
1720
- const out = (parsed.DeleteResult || parsed.deleteResult || parsed) as Record<string, unknown>;
1721
- const resultMap = new Map<string, boolean>();
1722
- for (const key of keys) {
1723
- resultMap.set(key, false);
1724
- }
1922
+ return (parsed.DeleteResult || parsed.deleteResult || parsed) as Record<string, unknown>;
1923
+ }
1924
+
1925
+ private _markDeletedKeys(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
1725
1926
  const deleted = out.deleted || out.Deleted;
1726
- if (deleted) {
1727
- const deletedArray = Array.isArray(deleted) ? deleted : [deleted];
1728
- for (const item of deletedArray) {
1729
- if (item && typeof item === 'object') {
1730
- const obj = item as Record<string, unknown>;
1731
- // Check both key and Key
1732
- const key = obj.key || obj.Key;
1733
- if (key && typeof key === 'string') {
1734
- resultMap.set(key, true);
1735
- }
1927
+ if (!deleted) {
1928
+ return;
1929
+ }
1930
+
1931
+ const items = Array.isArray(deleted) ? deleted : [deleted];
1932
+ for (const item of items) {
1933
+ if (item && typeof item === 'object') {
1934
+ const key = (item as Record<string, unknown>).key || (item as Record<string, unknown>).Key;
1935
+ if (key && typeof key === 'string') {
1936
+ resultMap.set(key, true);
1736
1937
  }
1737
1938
  }
1738
1939
  }
1940
+ }
1739
1941
 
1740
- // Handle errors (check both cases)
1942
+ private _logDeleteErrors(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
1741
1943
  const errors = out.error || out.Error;
1742
- if (errors) {
1743
- const errorsArray = Array.isArray(errors) ? errors : [errors];
1744
- for (const item of errorsArray) {
1745
- if (item && typeof item === 'object') {
1746
- const obj = item as Record<string, unknown>;
1747
- // Check both cases for all properties
1748
- const key = obj.key || obj.Key;
1749
- const code = obj.code || obj.Code;
1750
- const message = obj.message || obj.Message;
1751
-
1752
- if (key && typeof key === 'string') {
1753
- resultMap.set(key, false);
1754
- // Optionally log the error for debugging
1755
- this._log('warn', `Failed to delete object: ${key}`, {
1756
- code: code || 'Unknown',
1757
- message: message || 'Unknown error',
1758
- });
1759
- }
1944
+ if (!errors) {
1945
+ return;
1946
+ }
1947
+
1948
+ const items = Array.isArray(errors) ? errors : [errors];
1949
+ for (const item of items) {
1950
+ if (item && typeof item === 'object') {
1951
+ const obj = item as Record<string, unknown>;
1952
+ const key = obj.key || obj.Key;
1953
+ if (key && typeof key === 'string') {
1954
+ resultMap.set(key, false);
1955
+ this._log('warn', `Failed to delete object: ${key}`, {
1956
+ code: obj.code || obj.Code || 'Unknown',
1957
+ message: obj.message || obj.Message || 'Unknown error',
1958
+ });
1760
1959
  }
1761
1960
  }
1762
1961
  }
1763
-
1764
- // Return boolean array in the same order as input keys
1765
- return keys.map(key => resultMap.get(key) || false);
1766
1962
  }
1767
1963
 
1768
1964
  /**
@@ -1869,30 +2065,38 @@ class S3mini {
1869
2065
  * @param {'GET' | 'PUT'} method - HTTP method ('GET' for download, 'PUT' for upload)
1870
2066
  * @param {string} key - The object key/path
1871
2067
  * @param {number} [expiresIn=3600] - URL expiration time in seconds (1–604800)
1872
- * @param {Record<string, string>} [queryParams={}] - Additional query parameters to sign
2068
+ * @param {Record<string, string>} [queryParams={}] - Additional query parameters to include in the URL
2069
+ * @param {Record<string, string>} [headers={}] - HTTP headers to sign. The consumer of the URL
2070
+ * MUST send these exact headers with matching values. The `host` header is always signed automatically.
1873
2071
  * @returns {Promise<string>} Pre-signed URL string
1874
2072
  * @throws {TypeError} If key is empty or expiresIn is out of range
1875
2073
  * @example
1876
2074
  * // Download URL valid for 1 hour
1877
2075
  * const url = await s3.getPresignedUrl('GET', 'photos/vacation.jpg');
1878
2076
  *
1879
- * // Upload URL valid for 5 minutes
1880
- * const url = await s3.getPresignedUrl('PUT', 'uploads/file.bin', 300);
2077
+ * // Upload URL valid for 5 minutes with signed Content-Type
2078
+ * const url = await s3.getPresignedUrl('PUT', 'uploads/file.bin', 300, {}, {
2079
+ * 'Content-Type': 'application/octet-stream',
2080
+ * });
1881
2081
  *
1882
- * // Client-side usage (no credentials needed)
1883
- * await fetch(url, { method: 'PUT', body: data });
2082
+ * // Client-side usage (must include signed headers)
2083
+ * await fetch(url, { method: 'PUT', body: data, headers: { 'Content-Type': 'application/octet-stream' } });
1884
2084
  */
1885
2085
  public async getPresignedUrl(
1886
2086
  method: 'GET' | 'PUT',
1887
2087
  key: string,
1888
2088
  expiresIn: number = 3600,
1889
2089
  queryParams: Record<string, string> = {},
2090
+ headers: Record<string, string> = {},
1890
2091
  ): Promise<string> {
1891
2092
  this._checkKey(key);
1892
2093
  if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
1893
2094
  throw new TypeError(`${C.ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
1894
2095
  }
1895
- return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams);
2096
+ if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
2097
+ return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
2098
+ }
2099
+ return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
1896
2100
  }
1897
2101
 
1898
2102
  private async _presign(
@@ -1900,6 +2104,7 @@ class S3mini {
1900
2104
  keyPath: string,
1901
2105
  expiresIn: number,
1902
2106
  queryParams: Record<string, string>,
2107
+ headers: Record<string, string>,
1903
2108
  ): Promise<string> {
1904
2109
  const url = new URL(this.endpoint);
1905
2110
  if (keyPath.length > 0) {
@@ -1915,7 +2120,17 @@ class S3mini {
1915
2120
  const fullDatetime = `${shortDatetime}T${String(d.getUTCHours()).padStart(2, '0')}${String(d.getUTCMinutes()).padStart(2, '0')}${String(d.getUTCSeconds()).padStart(2, '0')}Z`;
1916
2121
  const credentialScope = `${shortDatetime}/${this.region}/${C.S3_SERVICE}/${C.AWS_REQUEST_TYPE}`;
1917
2122
 
1918
- const signedHeaders = 'host';
2123
+ const headerEntries: Array<[string, string]> = [['host', url.host]];
2124
+ for (const [key, value] of Object.entries(headers)) {
2125
+ const lowerKey = key.toLowerCase();
2126
+ if (lowerKey !== 'host') {
2127
+ headerEntries.push([lowerKey, String(value).trim()]);
2128
+ }
2129
+ }
2130
+ headerEntries.sort(([a], [b]) => a.localeCompare(b));
2131
+
2132
+ const canonicalHeaders = headerEntries.map(([k, v]) => `${k}:${v}`).join('\n');
2133
+ const signedHeaders = headerEntries.map(([k]) => k).join(';');
1919
2134
 
1920
2135
  const allQueryParams: Record<string, string> = {
1921
2136
  ...queryParams,
@@ -1927,7 +2142,7 @@ class S3mini {
1927
2142
  };
1928
2143
 
1929
2144
  const canonicalQueryString = this._buildCanonicalQueryString(allQueryParams);
1930
- const canonicalRequest = `${method}\n${url.pathname}\n${canonicalQueryString}\nhost:${url.host}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
2145
+ const canonicalRequest = `${method}\n${url.pathname}\n${canonicalQueryString}\n${canonicalHeaders}\n\n${signedHeaders}\n${C.UNSIGNED_PAYLOAD}`;
1931
2146
  const stringToSign = `${C.AWS_ALGORITHM}\n${fullDatetime}\n${credentialScope}\n${hexFromBuffer(await sha256(canonicalRequest))}`;
1932
2147
 
1933
2148
  if (shortDatetime !== this.signingKeyDate || !this.signingKey) {
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,23 @@
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
+
6
23
  const ENCODR = new TextEncoder();
7
24
  const chunkSize = 0x8000; // 32KB chunks
8
25
  const HEX_CHARS = new Uint8Array([48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 97, 98, 99, 100, 101, 102]);
@@ -149,7 +166,7 @@ const unescapeXml = (value: string): string =>
149
166
 
150
167
  export const parseXml = (input: string): XmlValue => {
151
168
  const xmlContent = input.replace(/<\?xml[^?]*\?>\s*/, '');
152
- const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]*>([\s\S]*?)<\/\1>/gm;
169
+ const RE_TAG = /<([A-Za-z_][\w\-.]*)[^>]*?>([\s\S]*?)<\/\1>/gm;
153
170
  const result: XmlMap = {}; // strong type, no `any`
154
171
  let match: RegExpExecArray | null;
155
172