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/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;
@@ -698,6 +725,90 @@ class S3mini {
698
725
  response.nextMarker) as string | undefined;
699
726
  }
700
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
+
701
812
  /**
702
813
  * Lists multipart uploads in the bucket.
703
814
  * This method sends a request to list multipart uploads in the specified bucket.
@@ -756,7 +867,9 @@ class S3mini {
756
867
  opts: Record<string, unknown> = {},
757
868
  ssecHeaders?: IT.SSECHeaders,
758
869
  ): Promise<string | null> {
759
- // 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
+ }
760
873
  const res = await this._signedRequest('GET', key, {
761
874
  query: opts, // use opts.query if it exists, otherwise use an empty object
762
875
  tolerated: [200, 404, 412, 304],
@@ -808,6 +921,9 @@ class S3mini {
808
921
  opts: Record<string, unknown> = {},
809
922
  ssecHeaders?: IT.SSECHeaders,
810
923
  ): Promise<ArrayBuffer | null> {
924
+ if (this._bun && !ssecHeaders && !Object.keys(opts).length) {
925
+ return this._bunRead(key, f => f.arrayBuffer());
926
+ }
811
927
  const res = await this._signedRequest('GET', key, {
812
928
  query: opts,
813
929
  tolerated: [200, 404, 412, 304],
@@ -833,6 +949,9 @@ class S3mini {
833
949
  opts: Record<string, unknown> = {},
834
950
  ssecHeaders?: IT.SSECHeaders,
835
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
+ }
836
955
  const res = await this._signedRequest('GET', key, {
837
956
  query: opts,
838
957
  tolerated: [200, 404, 412, 304],
@@ -858,6 +977,18 @@ class S3mini {
858
977
  opts: Record<string, unknown> = {},
859
978
  ssecHeaders?: IT.SSECHeaders,
860
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
+ }
861
992
  try {
862
993
  const res = await this._signedRequest('GET', key, {
863
994
  query: opts,
@@ -900,6 +1031,29 @@ class S3mini {
900
1031
  opts: Record<string, unknown> = {},
901
1032
  ssecHeaders?: IT.SSECHeaders,
902
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
+
903
1057
  let rangeHdr: Record<string, string | number> = {};
904
1058
 
905
1059
  if (!wholeFile) {
@@ -922,6 +1076,9 @@ class S3mini {
922
1076
  */
923
1077
  public async getContentLength(key: string, ssecHeaders?: IT.SSECHeaders): Promise<number> {
924
1078
  try {
1079
+ if (this._bun && !ssecHeaders) {
1080
+ return (await this._bun.file(key).stat()).size;
1081
+ }
925
1082
  const res = await this._signedRequest('HEAD', key, {
926
1083
  headers: ssecHeaders ? { ...ssecHeaders } : undefined,
927
1084
  });
@@ -943,6 +1100,9 @@ class S3mini {
943
1100
  * @returns A promise that resolves to true if the object exists, false if not found, or null if ETag mismatch.
944
1101
  */
945
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
+ }
946
1106
  const res = await this._signedRequest('HEAD', key, {
947
1107
  query: opts,
948
1108
  tolerated: [200, 404, 412, 304],
@@ -975,6 +1135,15 @@ class S3mini {
975
1135
  opts: Record<string, unknown> = {},
976
1136
  ssecHeaders?: IT.SSECHeaders,
977
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
+ }
978
1147
  const res = await this._signedRequest('HEAD', key, {
979
1148
  query: opts,
980
1149
  tolerated: [200, 304, 404, 412],
@@ -1022,6 +1191,12 @@ class S3mini {
1022
1191
  additionalHeaders?: IT.AWSHeaders,
1023
1192
  contentLength?: number,
1024
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
+ }
1025
1200
  const size = contentLength ?? getByteSize(data);
1026
1201
  return this._signedRequest('PUT', key, {
1027
1202
  body: data as BodyInit,
@@ -1062,6 +1237,15 @@ class S3mini {
1062
1237
  additionalHeaders?: IT.AWSHeaders,
1063
1238
  contentLength?: number,
1064
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
+
1065
1249
  const size = contentLength ?? getByteSize(data);
1066
1250
 
1067
1251
  // Single PUT for small files
@@ -1697,77 +1881,84 @@ class S3mini {
1697
1881
  * @returns A promise that resolves to true if the object was deleted successfully, false otherwise.
1698
1882
  */
1699
1883
  public async deleteObject(key: string): Promise<boolean> {
1884
+ if (this._bun) {
1885
+ await this._bun.file(key).delete();
1886
+ return true;
1887
+ }
1700
1888
  const res = await this._signedRequest('DELETE', key, { tolerated: [200, 204] });
1701
1889
  return res.status === 200 || res.status === 204;
1702
1890
  }
1703
1891
 
1704
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>> {
1705
1903
  const objectsXml = keys.map(key => `<Object><Key>${escapeXml(key)}</Key></Object>`).join('');
1706
1904
  const xmlBody = '<Delete>' + objectsXml + '</Delete>';
1707
- const query = { delete: '' };
1708
1905
  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
1906
 
1715
1907
  const res = await this._signedRequest('POST', '', {
1716
- query,
1908
+ query: { delete: '' },
1717
1909
  body: xmlBody,
1718
- 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
+ },
1719
1915
  withQuery: true,
1720
1916
  });
1917
+
1721
1918
  const parsed = parseXml(await res.text()) as Record<string, unknown>;
1722
1919
  if (!parsed || typeof parsed !== 'object') {
1723
1920
  throw new Error(`${C.ERROR_PREFIX}Failed to delete objects: ${JSON.stringify(parsed)}`);
1724
1921
  }
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
- }
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 {
1730
1926
  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
- }
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);
1741
1937
  }
1742
1938
  }
1743
1939
  }
1940
+ }
1744
1941
 
1745
- // Handle errors (check both cases)
1942
+ private _logDeleteErrors(out: Record<string, unknown>, resultMap: Map<string, boolean>): void {
1746
1943
  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
- }
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
+ });
1765
1959
  }
1766
1960
  }
1767
1961
  }
1768
-
1769
- // Return boolean array in the same order as input keys
1770
- return keys.map(key => resultMap.get(key) || false);
1771
1962
  }
1772
1963
 
1773
1964
  /**
@@ -1902,6 +2093,9 @@ class S3mini {
1902
2093
  if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 604800) {
1903
2094
  throw new TypeError(`${C.ERROR_PREFIX}expiresIn must be between 1 and 604800 seconds`);
1904
2095
  }
2096
+ if (this._bun && !Object.keys(queryParams).length && !Object.keys(headers).length) {
2097
+ return this._bun.presign(key, { method, expiresIn: Math.floor(expiresIn) });
2098
+ }
1905
2099
  return this._presign(method, uriResourceEscape(key), Math.floor(expiresIn), queryParams, headers);
1906
2100
  }
1907
2101
 
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]);