lean-s3 0.3.2 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,7 +79,7 @@ BUT...
79
79
 
80
80
  Due to the scalability, portability and AWS integrations of @aws-sdk/client-s3, pre-signing URLs is `async` and performs poorly in high-performance scenarios. By taking different trade-offs, lean-s3 can presign URLs much faster. I promise! This is the reason you cannot use lean-s3 in the browser.
81
81
 
82
- lean-s3 is currently about 30x faster than AWS SDK when it comes to pre-signing URLs[^1]:
82
+ lean-s3 is currently about 30x faster than AWS SDK when it comes to pre-signing URLs:
83
83
  ```
84
84
  benchmark avg (min … max) p75 / p99
85
85
  -------------------------------------------- ---------
@@ -106,7 +106,9 @@ summary
106
106
  30.99x faster than @aws-sdk/s3-request-presigner
107
107
  ```
108
108
 
109
- Don't trust this benchmark and run it yourself[^2]. I am just some random internet guy trying to tell you [how much better this s3 client is](https://xkcd.com/927/). For `PUT` operations, it is ~1.45x faster than `@aws-sdk/client-s3`. We still work on improving these numbers.
109
+ Don't trust this benchmark and [run it yourself](./BENCHMARKS.md). I am just some random internet guy trying to tell you [how much better this s3 client is](https://xkcd.com/927/). For `PUT` operations, it is ~1.5x faster than `@aws-sdk/client-s3`. We still work on improving these numbers.
110
+
111
+ See [BENCHMARKS.md](./BENCHMARKS.md) for more numbers and how to run it yourself. PRs for improving the benchmarks are welcome!
110
112
 
111
113
  ## Why not lean-s3?
112
114
  Don't use lean-s3 if you
@@ -134,6 +136,9 @@ See [DESIGN_DECISIONS.md](./DESIGN_DECISIONS.md) to read about why this library
134
136
  - ✅ [`DeleteObject`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html) via `S3File.delete`
135
137
  - ✅ [`PutObject`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html) via `S3File.write`
136
138
  - ✅ [`HeadObject`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html) via `S3File.exists`/`S3File.stat`
139
+ - ✅ [`ListMultipartUploads`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListMultipartUploads.html) via `.listMultipartUploads`
140
+ - ✅ [`CompleteMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html) via `.completeMultipartUpload`
141
+ - ✅ [`AbortMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html) via `.abortMultipartUpload`
137
142
 
138
143
  ## Example Configurations
139
144
  ### Hetzner Object Storage
@@ -171,6 +176,3 @@ const client = new S3Client({
171
176
  ```
172
177
 
173
178
  Popular S3 provider missing? Open an issue or file a PR!
174
-
175
- [^1]: Benchmark ran on a `13th Gen Intel(R) Core(TM) i7-1370P` using Node.js `23.11.0`. See `bench/` directory for the used benchmark.
176
- [^2]: `git clone git@github.com:nikeee/lean-s3.git && cd lean-s3 && npm ci && npm run build && cd bench && npm ci && npm start`
package/dist/index.d.ts CHANGED
@@ -59,6 +59,75 @@ type ListObjectsIteratingOptions = {
59
59
  signal?: AbortSignal;
60
60
  internalPageSize?: number;
61
61
  };
62
+ type ListMultipartUploadsOptions = {
63
+ bucket?: string;
64
+ delimiter?: string;
65
+ keyMarker?: string;
66
+ maxUploads?: number;
67
+ prefix?: string;
68
+ uploadIdMarker?: string;
69
+ signal?: AbortSignal;
70
+ };
71
+ type ListMultipartUploadsResult = {
72
+ bucket?: string;
73
+ keyMarker?: string;
74
+ uploadIdMarker?: string;
75
+ nextKeyMarker?: string;
76
+ prefix?: string;
77
+ delimiter?: string;
78
+ nextUploadIdMarker?: string;
79
+ maxUploads?: number;
80
+ isTruncated?: boolean;
81
+ uploads: MultipartUpload[];
82
+ };
83
+ type MultipartUpload = {
84
+ checksumAlgorithm?: ChecksumAlgorithm;
85
+ checksumType?: ChecksumType;
86
+ initiated?: Date;
87
+ /**
88
+ * Key of the object for which the multipart upload was initiated.
89
+ * Length Constraints: Minimum length of 1.
90
+ */
91
+ key?: string;
92
+ storageClass?: StorageClass;
93
+ /**
94
+ * Upload ID identifying the multipart upload.
95
+ */
96
+ uploadId?: string;
97
+ };
98
+ type CreateMultipartUploadOptions = {
99
+ bucket?: string;
100
+ signal?: AbortSignal;
101
+ };
102
+ type CreateMultipartUploadResponse = {
103
+ bucket: string;
104
+ key: string;
105
+ uploadId: string;
106
+ };
107
+ type AbortMultipartUploadOptions = {
108
+ bucket?: string;
109
+ signal?: AbortSignal;
110
+ };
111
+ type CompleteMultipartUploadOptions = {
112
+ bucket?: string;
113
+ signal?: AbortSignal;
114
+ };
115
+ type CompleteMultipartUploadResult = {
116
+ location?: string;
117
+ bucket?: string;
118
+ key?: string;
119
+ etag?: string;
120
+ checksumCRC32?: string;
121
+ checksumCRC32C?: string;
122
+ checksumCRC64NVME?: string;
123
+ checksumSHA1?: string;
124
+ checksumSHA256?: string;
125
+ checksumType?: ChecksumType;
126
+ };
127
+ type MultipartUploadPart = {
128
+ partNumber: number;
129
+ etag: string;
130
+ };
62
131
  type ListObjectsResponse = {
63
132
  name: string;
64
133
  prefix: string | undefined;
@@ -129,7 +198,7 @@ declare class S3Client {
129
198
  *
130
199
  * lean-s3 does not enforce these restrictions.
131
200
  *
132
- * @param {Partial<CreateFileInstanceOptions>} [options] TODO
201
+ * @param {Partial<CreateFileInstanceOptions>} [_options] TODO
133
202
  * @example
134
203
  * ```js
135
204
  * const file = client.file("image.jpg");
@@ -141,7 +210,7 @@ declare class S3Client {
141
210
  * });
142
211
  * ```
143
212
  */
144
- file(path: string, options?: Partial<CreateFileInstanceOptions>): S3File;
213
+ file(path: string, _options?: Partial<CreateFileInstanceOptions>): S3File;
145
214
  /**
146
215
  * Generate a presigned URL for temporary access to a file.
147
216
  * Useful for generating upload/download URLs without exposing credentials.
@@ -156,17 +225,35 @@ declare class S3Client {
156
225
  */
157
226
  presign(path: string, { method, expiresIn, // TODO: Maybe rename this to expiresInSeconds
158
227
  storageClass, acl, region: regionOverride, bucket: bucketOverride, endpoint: endpointOverride, }?: Partial<S3FilePresignOptions & OverridableS3ClientOptions>): string;
228
+ createMultipartUpload(key: string, options?: CreateMultipartUploadOptions): Promise<CreateMultipartUploadResponse>;
159
229
  /**
160
- * Uses [`DeleteObjects`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) to delete multiple objects in a single request.
230
+ * @remarks Uses [`ListMultipartUploads`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListMultipartUploads.html).
231
+ * @throws {RangeError} If `options.maxKeys` is not between `1` and `1000`.
161
232
  */
162
- deleteObjects(objects: readonly S3BucketEntry[] | readonly string[], options?: DeleteObjectsOptions): Promise<{
163
- errors: {
164
- code: any;
165
- key: any;
166
- message: any;
167
- versionId: any;
168
- }[];
169
- } | null>;
233
+ listMultipartUploads(options?: ListMultipartUploadsOptions): Promise<ListMultipartUploadsResult>;
234
+ /**
235
+ * @remarks Uses [`AbortMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html).
236
+ * @throws {RangeError} If `key` is not at least 1 character long.
237
+ * @throws {Error} If `uploadId` is not provided.
238
+ */
239
+ abortMultipartUpload(key: string, uploadId: string, options?: AbortMultipartUploadOptions): Promise<void>;
240
+ /**
241
+ * @remarks Uses [`CompleteMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html).
242
+ * @throws {RangeError} If `key` is not at least 1 character long.
243
+ * @throws {Error} If `uploadId` is not provided.
244
+ */
245
+ completeMultipartUpload(key: string, uploadId: string, parts: readonly MultipartUploadPart[], options?: CompleteMultipartUploadOptions): Promise<{
246
+ location: any;
247
+ bucket: any;
248
+ key: any;
249
+ etag: any;
250
+ checksumCRC32: any;
251
+ checksumCRC32C: any;
252
+ checksumCRC64NVME: any;
253
+ checksumSHA1: any;
254
+ checksumSHA256: any;
255
+ checksumType: any;
256
+ }>;
170
257
  /**
171
258
  * Creates a new bucket on the S3 server.
172
259
  *
@@ -203,8 +290,16 @@ declare class S3Client {
203
290
  listIterating(options: ListObjectsIteratingOptions): AsyncGenerator<S3BucketEntry>;
204
291
  /**
205
292
  * Implements [`ListObjectsV2`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) to iterate over all keys.
293
+ *
294
+ * @throws {RangeError} If `maxKeys` is not between `1` and `1000`.
206
295
  */
207
296
  list(options?: ListObjectsOptions): Promise<ListObjectsResponse>;
297
+ /**
298
+ * Uses [`DeleteObjects`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) to delete multiple objects in a single request.
299
+ */
300
+ deleteObjects(objects: readonly S3BucketEntry[] | readonly string[], options?: DeleteObjectsOptions): Promise<{
301
+ errors: any;
302
+ } | null>;
208
303
  /**
209
304
  * Do not use this. This is an internal method.
210
305
  * TODO: Maybe move this into a separate free function?
@@ -338,4 +433,4 @@ type BucketInfo = {
338
433
  type?: string;
339
434
  };
340
435
 
341
- export { type Acl, type BucketCreationOptions, type BucketDeletionOptions, type BucketExistsOptions, type BucketInfo, type BucketLocationInfo, type ByteSource, type ChecksumAlgorithm, type ChecksumType, type CreateFileInstanceOptions, type DeleteObjectsOptions, type HttpMethod, type ListObjectsIteratingOptions, type ListObjectsOptions, type ListObjectsResponse, type OverridableS3ClientOptions, type PresignableHttpMethod, S3BucketEntry, S3Client, type S3ClientOptions, S3Error, type S3ErrorOptions, S3File, type S3FileDeleteOptions, type S3FileExistsOptions, type S3FilePresignOptions, S3Stat, type S3StatOptions, type StorageClass, type UndiciBodyInit };
436
+ export { type AbortMultipartUploadOptions, type Acl, type BucketCreationOptions, type BucketDeletionOptions, type BucketExistsOptions, type BucketInfo, type BucketLocationInfo, type ByteSource, type ChecksumAlgorithm, type ChecksumType, type CompleteMultipartUploadOptions, type CompleteMultipartUploadResult, type CreateFileInstanceOptions, type CreateMultipartUploadOptions, type CreateMultipartUploadResponse, type DeleteObjectsOptions, type HttpMethod, type ListMultipartUploadsOptions, type ListMultipartUploadsResult, type ListObjectsIteratingOptions, type ListObjectsOptions, type ListObjectsResponse, type MultipartUpload, type MultipartUploadPart, type OverridableS3ClientOptions, type PresignableHttpMethod, S3BucketEntry, S3Client, type S3ClientOptions, S3Error, type S3ErrorOptions, S3File, type S3FileDeleteOptions, type S3FileExistsOptions, type S3FilePresignOptions, S3Stat, type S3StatOptions, type StorageClass, type UndiciBodyInit };
package/dist/index.js CHANGED
@@ -1,28 +1,6 @@
1
1
  // src/S3File.ts
2
2
  import { Readable } from "stream";
3
3
 
4
- // src/S3Error.ts
5
- var S3Error = class extends Error {
6
- code;
7
- path;
8
- message;
9
- requestId;
10
- hostId;
11
- constructor(code, path, {
12
- message = void 0,
13
- requestId = void 0,
14
- hostId = void 0,
15
- cause = void 0
16
- } = {}) {
17
- super(message, { cause });
18
- this.code = code;
19
- this.path = path;
20
- this.message = message ?? "Some unknown error occurred.";
21
- this.requestId = requestId;
22
- this.hostId = hostId;
23
- }
24
- };
25
-
26
4
  // src/S3Stat.ts
27
5
  var S3Stat = class _S3Stat {
28
6
  etag;
@@ -64,6 +42,28 @@ var S3Stat = class _S3Stat {
64
42
  import { request, Agent } from "undici";
65
43
  import { XMLParser as XMLParser2, XMLBuilder } from "fast-xml-parser";
66
44
 
45
+ // src/S3Error.ts
46
+ var S3Error = class extends Error {
47
+ code;
48
+ path;
49
+ message;
50
+ requestId;
51
+ hostId;
52
+ constructor(code, path, {
53
+ message = void 0,
54
+ requestId = void 0,
55
+ hostId = void 0,
56
+ cause = void 0
57
+ } = {}) {
58
+ super(message, { cause });
59
+ this.code = code;
60
+ this.path = path;
61
+ this.message = message ?? "Some unknown error occurred.";
62
+ this.requestId = requestId;
63
+ this.hostId = hostId;
64
+ }
65
+ };
66
+
67
67
  // src/S3BucketEntry.ts
68
68
  var S3BucketEntry = class _S3BucketEntry {
69
69
  key;
@@ -238,7 +238,7 @@ function getRangeHeader(start, endExclusive) {
238
238
  import { XMLParser } from "fast-xml-parser";
239
239
  var xmlParser = new XMLParser();
240
240
  async function getResponseError(response, path) {
241
- let body = void 0;
241
+ let body;
242
242
  try {
243
243
  body = await response.body.text();
244
244
  } catch (cause) {
@@ -270,7 +270,7 @@ function fromStatusCode(code, path) {
270
270
  }
271
271
  }
272
272
  function parseAndGetXmlError(body, path) {
273
- let error = void 0;
273
+ let error;
274
274
  try {
275
275
  error = xmlParser.parse(body);
276
276
  } catch (cause) {
@@ -322,7 +322,10 @@ function getAuthorizationHeader(keyCache, method, path, query, date, sortedSigne
322
322
  var write = Symbol("write");
323
323
  var stream = Symbol("stream");
324
324
  var signedRequest = Symbol("signedRequest");
325
- var xmlParser2 = new XMLParser2();
325
+ var xmlParser2 = new XMLParser2({
326
+ ignoreAttributes: true,
327
+ isArray: (_, jPath) => jPath === "ListMultipartUploadsResult.Upload" || jPath === "ListBucketResult.Contents" || jPath === "DeleteResult.Deleted" || jPath === "DeleteResult.Error"
328
+ });
326
329
  var xmlBuilder = new XMLBuilder({
327
330
  attributeNamePrefix: "$",
328
331
  ignoreAttributes: false
@@ -396,7 +399,7 @@ var S3Client = class {
396
399
  *
397
400
  * lean-s3 does not enforce these restrictions.
398
401
  *
399
- * @param {Partial<CreateFileInstanceOptions>} [options] TODO
402
+ * @param {Partial<CreateFileInstanceOptions>} [_options] TODO
400
403
  * @example
401
404
  * ```js
402
405
  * const file = client.file("image.jpg");
@@ -408,7 +411,7 @@ var S3Client = class {
408
411
  * });
409
412
  * ```
410
413
  */
411
- file(path, options) {
414
+ file(path, _options) {
412
415
  return new S3File(this, path, void 0, void 0, void 0);
413
416
  }
414
417
  /**
@@ -471,64 +474,189 @@ var S3Client = class {
471
474
  res.search = `${query}&X-Amz-Signature=${signature}`;
472
475
  return res.toString();
473
476
  }
477
+ //#region multipart uploads
478
+ async createMultipartUpload(key, options = {}) {
479
+ if (key.length < 1) {
480
+ throw new RangeError("`key` must be at least 1 character long.");
481
+ }
482
+ const response = await this[signedRequest](
483
+ "POST",
484
+ key,
485
+ "uploads=",
486
+ void 0,
487
+ void 0,
488
+ void 0,
489
+ void 0,
490
+ ensureValidBucketName(options.bucket ?? this.#options.bucket),
491
+ options.signal
492
+ );
493
+ if (response.statusCode !== 200) {
494
+ throw await getResponseError(response, key);
495
+ }
496
+ const text = await response.body.text();
497
+ const res = ensureParsedXml(text).InitiateMultipartUploadResult ?? {};
498
+ return {
499
+ bucket: res.Bucket,
500
+ key: res.Key,
501
+ uploadId: res.UploadId
502
+ };
503
+ }
474
504
  /**
475
- * Uses [`DeleteObjects`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) to delete multiple objects in a single request.
505
+ * @remarks Uses [`ListMultipartUploads`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListMultipartUploads.html).
506
+ * @throws {RangeError} If `options.maxKeys` is not between `1` and `1000`.
476
507
  */
477
- async deleteObjects(objects, options = {}) {
508
+ async listMultipartUploads(options = {}) {
509
+ const bucket = ensureValidBucketName(
510
+ options.bucket ?? this.#options.bucket
511
+ );
512
+ let query = "uploads=";
513
+ if (options.delimiter) {
514
+ if (typeof options.delimiter !== "string") {
515
+ throw new TypeError("`delimiter` should be a `string`.");
516
+ }
517
+ query += `&delimiter=${encodeURIComponent(options.delimiter)}`;
518
+ }
519
+ if (options.keyMarker) {
520
+ if (typeof options.keyMarker !== "string") {
521
+ throw new TypeError("`keyMarker` should be a `string`.");
522
+ }
523
+ query += `&key-marker=${encodeURIComponent(options.keyMarker)}`;
524
+ }
525
+ if (typeof options.maxUploads !== "undefined") {
526
+ if (typeof options.maxUploads !== "number") {
527
+ throw new TypeError("`maxUploads` should be a `number`.");
528
+ }
529
+ if (options.maxUploads < 1 || options.maxUploads > 1e3) {
530
+ throw new RangeError("`maxUploads` should be between 1 and 1000.");
531
+ }
532
+ query += `&max-uploads=${options.maxUploads}`;
533
+ }
534
+ if (options.prefix) {
535
+ if (typeof options.prefix !== "string") {
536
+ throw new TypeError("`prefix` should be a `string`.");
537
+ }
538
+ query += `&prefix=${encodeURIComponent(options.prefix)}`;
539
+ }
540
+ const response = await this[signedRequest](
541
+ "GET",
542
+ "",
543
+ query,
544
+ void 0,
545
+ void 0,
546
+ void 0,
547
+ void 0,
548
+ bucket,
549
+ options.signal
550
+ );
551
+ if (response.statusCode !== 200) {
552
+ throw await getResponseError(response, "");
553
+ }
554
+ const text = await response.body.text();
555
+ const root = ensureParsedXml(text).ListMultipartUploadsResult ?? {};
556
+ return {
557
+ bucket: root.Bucket || void 0,
558
+ delimiter: root.Delimiter || void 0,
559
+ prefix: root.Prefix || void 0,
560
+ keyMarker: root.KeyMarker || void 0,
561
+ uploadIdMarker: root.UploadIdMarker || void 0,
562
+ nextKeyMarker: root.NextKeyMarker || void 0,
563
+ nextUploadIdMarker: root.NextUploadIdMarker || void 0,
564
+ maxUploads: root.MaxUploads ?? 1e3,
565
+ // not using || to not override 0; caution: minio supports 10000(!)
566
+ isTruncated: root.IsTruncated === "true",
567
+ uploads: root.Upload?.map(
568
+ // biome-ignore lint/suspicious/noExplicitAny: we're parsing here
569
+ (u) => ({
570
+ key: u.Key || void 0,
571
+ uploadId: u.UploadId || void 0,
572
+ // TODO: Initiator
573
+ // TODO: Owner
574
+ storageClass: u.StorageClass || void 0,
575
+ checksumAlgorithm: u.ChecksumAlgorithm || void 0,
576
+ checksumType: u.ChecksumType || void 0,
577
+ initiated: u.Initiated ? new Date(u.Initiated) : void 0
578
+ })
579
+ ) ?? []
580
+ };
581
+ }
582
+ /**
583
+ * @remarks Uses [`AbortMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html).
584
+ * @throws {RangeError} If `key` is not at least 1 character long.
585
+ * @throws {Error} If `uploadId` is not provided.
586
+ */
587
+ async abortMultipartUpload(key, uploadId, options = {}) {
588
+ if (key.length < 1) {
589
+ throw new RangeError("`key` must be at least 1 character long.");
590
+ }
591
+ if (!uploadId) {
592
+ throw new Error("`uploadId` is required.");
593
+ }
594
+ const response = await this[signedRequest](
595
+ "DELETE",
596
+ key,
597
+ `uploadId=${encodeURIComponent(uploadId)}`,
598
+ void 0,
599
+ void 0,
600
+ void 0,
601
+ void 0,
602
+ ensureValidBucketName(options.bucket ?? this.#options.bucket),
603
+ options.signal
604
+ );
605
+ if (response.statusCode !== 204) {
606
+ throw await getResponseError(response, key);
607
+ }
608
+ }
609
+ /**
610
+ * @remarks Uses [`CompleteMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html).
611
+ * @throws {RangeError} If `key` is not at least 1 character long.
612
+ * @throws {Error} If `uploadId` is not provided.
613
+ */
614
+ async completeMultipartUpload(key, uploadId, parts, options = {}) {
615
+ if (key.length < 1) {
616
+ throw new RangeError("`key` must be at least 1 character long.");
617
+ }
618
+ if (!uploadId) {
619
+ throw new Error("`uploadId` is required.");
620
+ }
478
621
  const body = xmlBuilder.build({
479
- Delete: {
480
- Quiet: true,
481
- Object: objects.map((o) => ({
482
- Key: typeof o === "string" ? o : o.key
622
+ CompleteMultipartUpload: {
623
+ Part: parts.map((part) => ({
624
+ PartNumber: part.partNumber,
625
+ ETag: part.etag
483
626
  }))
484
627
  }
485
628
  });
486
629
  const response = await this[signedRequest](
487
630
  "POST",
488
- "",
489
- "delete=",
490
- // "=" is needed by minio for some reason
631
+ key,
632
+ `uploadId=${encodeURIComponent(uploadId)}`,
491
633
  body,
492
- {
493
- "content-md5": md5Base64(body)
494
- },
495
634
  void 0,
496
635
  void 0,
497
- this.#options.bucket,
636
+ void 0,
637
+ ensureValidBucketName(options.bucket ?? this.#options.bucket),
498
638
  options.signal
499
639
  );
500
- if (response.statusCode === 200) {
501
- const text = await response.body.text();
502
- let res = void 0;
503
- try {
504
- res = (xmlParser2.parse(text)?.DeleteResult || void 0)?.Error ?? [];
505
- } catch (cause) {
506
- throw new S3Error("Unknown", "", {
507
- message: "S3 service responded with invalid XML.",
508
- cause
509
- });
510
- }
511
- if (!res || !Array.isArray(res)) {
512
- throw new S3Error("Unknown", "", {
513
- message: "Could not process response."
514
- });
515
- }
516
- const errors = res.map((e) => ({
517
- code: e.Code,
518
- key: e.Key,
519
- message: e.Message,
520
- versionId: e.VersionId
521
- }));
522
- return errors.length > 0 ? { errors } : null;
523
- }
524
- if (400 <= response.statusCode && response.statusCode < 500) {
525
- throw await getResponseError(response, "");
640
+ if (response.statusCode !== 200) {
641
+ throw await getResponseError(response, key);
526
642
  }
527
- response.body.dump();
528
- throw new Error(
529
- `Response code not implemented yet: ${response.statusCode}`
530
- );
643
+ const text = await response.body.text();
644
+ const res = ensureParsedXml(text).CompleteMultipartUploadResult ?? {};
645
+ return {
646
+ location: res.Location || void 0,
647
+ bucket: res.Bucket || void 0,
648
+ key: res.Key || void 0,
649
+ etag: res.ETag || void 0,
650
+ checksumCRC32: res.ChecksumCRC32 || void 0,
651
+ checksumCRC32C: res.ChecksumCRC32C || void 0,
652
+ checksumCRC64NVME: res.ChecksumCRC64NVME || void 0,
653
+ checksumSHA1: res.ChecksumSHA1 || void 0,
654
+ checksumSHA256: res.ChecksumSHA256 || void 0,
655
+ checksumType: res.ChecksumType || void 0
656
+ };
531
657
  }
658
+ //#endregion
659
+ //#region bucket operations
532
660
  /**
533
661
  * Creates a new bucket on the S3 server.
534
662
  *
@@ -544,8 +672,7 @@ var S3Client = class {
544
672
  * @remarks Uses [`CreateBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html)
545
673
  */
546
674
  async createBucket(name, options) {
547
- ensureValidBucketName(name);
548
- let body = void 0;
675
+ let body;
549
676
  if (options) {
550
677
  const location = options.location && (options.location.name || options.location.type) ? {
551
678
  Name: options.location.name ?? void 0,
@@ -573,7 +700,7 @@ var S3Client = class {
573
700
  additionalSignedHeaders,
574
701
  void 0,
575
702
  void 0,
576
- name,
703
+ ensureValidBucketName(name),
577
704
  options?.signal
578
705
  );
579
706
  if (400 <= response.statusCode && response.statusCode < 500) {
@@ -593,7 +720,6 @@ var S3Client = class {
593
720
  * @remarks Uses [`DeleteBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html).
594
721
  */
595
722
  async deleteBucket(name, options) {
596
- ensureValidBucketName(name);
597
723
  const response = await this[signedRequest](
598
724
  "DELETE",
599
725
  "",
@@ -602,7 +728,7 @@ var S3Client = class {
602
728
  void 0,
603
729
  void 0,
604
730
  void 0,
605
- name,
731
+ ensureValidBucketName(name),
606
732
  options?.signal
607
733
  );
608
734
  if (400 <= response.statusCode && response.statusCode < 500) {
@@ -621,7 +747,6 @@ var S3Client = class {
621
747
  * @remarks Uses [`HeadBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html).
622
748
  */
623
749
  async bucketExists(name, options) {
624
- ensureValidBucketName(name);
625
750
  const response = await this[signedRequest](
626
751
  "HEAD",
627
752
  "",
@@ -630,7 +755,7 @@ var S3Client = class {
630
755
  void 0,
631
756
  void 0,
632
757
  void 0,
633
- name,
758
+ ensureValidBucketName(name),
634
759
  options?.signal
635
760
  );
636
761
  if (response.statusCode !== 404 && 400 <= response.statusCode && response.statusCode < 500) {
@@ -645,16 +770,16 @@ var S3Client = class {
645
770
  }
646
771
  throw new Error(`Response code not supported: ${response.statusCode}`);
647
772
  }
648
- //#region list
773
+ //#endregion
774
+ //#region list objects
649
775
  /**
650
776
  * Uses [`ListObjectsV2`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) to iterate over all keys. Pagination and continuation is handled internally.
651
777
  */
652
778
  async *listIterating(options) {
653
779
  const maxKeys = options?.internalPageSize ?? void 0;
654
- let res = void 0;
655
- let continuationToken = void 0;
780
+ let continuationToken;
656
781
  do {
657
- res = await this.list({
782
+ const res = await this.list({
658
783
  ...options,
659
784
  maxKeys,
660
785
  continuationToken
@@ -668,6 +793,8 @@ var S3Client = class {
668
793
  }
669
794
  /**
670
795
  * Implements [`ListObjectsV2`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) to iterate over all keys.
796
+ *
797
+ * @throws {RangeError} If `maxKeys` is not between `1` and `1000`.
671
798
  */
672
799
  async list(options = {}) {
673
800
  let query = "";
@@ -682,6 +809,9 @@ var S3Client = class {
682
809
  if (typeof options.maxKeys !== "number") {
683
810
  throw new TypeError("`maxKeys` should be a `number`.");
684
811
  }
812
+ if (options.maxKeys < 1 || options.maxKeys > 1e3) {
813
+ throw new RangeError("`maxKeys` should be between 1 and 1000.");
814
+ }
685
815
  query += `&max-keys=${options.maxKeys}`;
686
816
  }
687
817
  if (options.prefix) {
@@ -707,41 +837,88 @@ var S3Client = class {
707
837
  options.bucket ?? this.#options.bucket,
708
838
  options.signal
709
839
  );
840
+ if (response.statusCode !== 200) {
841
+ response.body.dump();
842
+ throw new Error(
843
+ `Response code not implemented yet: ${response.statusCode}`
844
+ );
845
+ }
846
+ const text = await response.body.text();
847
+ const res = ensureParsedXml(text).ListBucketResult ?? {};
848
+ if (!res) {
849
+ throw new S3Error("Unknown", "", {
850
+ message: "Could not read bucket contents."
851
+ });
852
+ }
853
+ return {
854
+ name: res.Name,
855
+ prefix: res.Prefix,
856
+ startAfter: res.StartAfter,
857
+ isTruncated: res.IsTruncated,
858
+ continuationToken: res.ContinuationToken,
859
+ maxKeys: res.MaxKeys,
860
+ keyCount: res.KeyCount,
861
+ nextContinuationToken: res.NextContinuationToken,
862
+ contents: res.Contents?.map(S3BucketEntry.parse) ?? []
863
+ };
864
+ }
865
+ //#endregion
866
+ /**
867
+ * Uses [`DeleteObjects`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) to delete multiple objects in a single request.
868
+ */
869
+ async deleteObjects(objects, options = {}) {
870
+ const body = xmlBuilder.build({
871
+ Delete: {
872
+ Quiet: true,
873
+ Object: objects.map((o) => ({
874
+ Key: typeof o === "string" ? o : o.key
875
+ }))
876
+ }
877
+ });
878
+ const response = await this[signedRequest](
879
+ "POST",
880
+ "",
881
+ "delete=",
882
+ // "=" is needed by minio for some reason
883
+ body,
884
+ {
885
+ "content-md5": md5Base64(body)
886
+ },
887
+ void 0,
888
+ void 0,
889
+ this.#options.bucket,
890
+ options.signal
891
+ );
710
892
  if (response.statusCode === 200) {
711
893
  const text = await response.body.text();
712
- let res = void 0;
894
+ let deleteResult;
713
895
  try {
714
- res = xmlParser2.parse(text)?.ListBucketResult;
896
+ deleteResult = ensureParsedXml(text).DeleteResult ?? {};
715
897
  } catch (cause) {
716
898
  throw new S3Error("Unknown", "", {
717
899
  message: "S3 service responded with invalid XML.",
718
900
  cause
719
901
  });
720
902
  }
721
- if (!res) {
722
- throw new S3Error("Unknown", "", {
723
- message: "Could not read bucket contents."
724
- });
725
- }
726
- const contents = Array.isArray(res.Contents) ? res.Contents?.map(S3BucketEntry.parse) ?? [] : res.Contents ? [res.Contents] : [];
727
- return {
728
- name: res.Name,
729
- prefix: res.Prefix,
730
- startAfter: res.StartAfter,
731
- isTruncated: res.IsTruncated,
732
- continuationToken: res.ContinuationToken,
733
- maxKeys: res.MaxKeys,
734
- keyCount: res.KeyCount,
735
- nextContinuationToken: res.NextContinuationToken,
736
- contents
737
- };
903
+ const errors = (
904
+ // biome-ignore lint/suspicious/noExplicitAny: parsing
905
+ deleteResult.Error?.map((e) => ({
906
+ code: e.Code,
907
+ key: e.Key,
908
+ message: e.Message,
909
+ versionId: e.VersionId
910
+ })) ?? []
911
+ );
912
+ return errors.length > 0 ? { errors } : null;
913
+ }
914
+ if (400 <= response.statusCode && response.statusCode < 500) {
915
+ throw await getResponseError(response, "");
738
916
  }
739
917
  response.body.dump();
740
918
  throw new Error(
741
919
  `Response code not implemented yet: ${response.statusCode}`
742
920
  );
743
921
  }
744
- //#endregion
745
922
  /**
746
923
  * Do not use this. This is an internal method.
747
924
  * TODO: Maybe move this into a separate free function?
@@ -819,7 +996,7 @@ var S3Client = class {
819
996
  "x-amz-content-sha256": contentHashStr,
820
997
  "x-amz-date": now2.dateTime
821
998
  });
822
- let response = void 0;
999
+ let response;
823
1000
  try {
824
1001
  response = await request(url, {
825
1002
  method: "PUT",
@@ -942,10 +1119,9 @@ var S3Client = class {
942
1119
  }
943
1120
  if (400 <= status && status < 500) {
944
1121
  const responseText = void 0;
945
- const ct = response.headers["content-type"];
946
1122
  if (response.headers["content-type"] === "application/xml") {
947
1123
  return response.body.text().then((body) => {
948
- let error = void 0;
1124
+ let error;
949
1125
  try {
950
1126
  error = xmlParser2.parse(body);
951
1127
  } catch (cause) {
@@ -1020,6 +1196,23 @@ function ensureValidBucketName(name) {
1020
1196
  if (name.includes("..")) {
1021
1197
  throw new Error("`name` must not contain two adjacent periods (..)");
1022
1198
  }
1199
+ return name;
1200
+ }
1201
+ function ensureParsedXml(text) {
1202
+ try {
1203
+ const r = xmlParser2.parse(text);
1204
+ if (!r) {
1205
+ throw new S3Error("Unknown", "", {
1206
+ message: "S3 service responded with empty XML."
1207
+ });
1208
+ }
1209
+ return r;
1210
+ } catch (cause) {
1211
+ throw new S3Error("Unknown", "", {
1212
+ message: "S3 service responded with invalid XML.",
1213
+ cause
1214
+ });
1215
+ }
1023
1216
  }
1024
1217
 
1025
1218
  // src/assertNever.ts
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "lean-s3",
3
3
  "author": "Niklas Mollenhauer",
4
4
  "license": "MIT",
5
- "version": "0.3.2",
5
+ "version": "0.4.1",
6
6
  "description": "A server-side S3 API for the regular user.",
7
7
  "keywords": [
8
8
  "s3",
@@ -34,8 +34,8 @@
34
34
  "type": "module",
35
35
  "scripts": {
36
36
  "build": "tsup",
37
- "test": "tsgo && node --test src/*.test.ts",
38
- "test:integration": "tsgo && node --test src/test.integration.ts",
37
+ "test": "tsgo && tsx --test src/*.test.ts",
38
+ "test:integration": "tsgo && tsx --test src/test.integration.ts",
39
39
  "ci": "biome ci ./src",
40
40
  "docs": "typedoc",
41
41
  "lint": "biome lint ./src",
@@ -47,17 +47,18 @@
47
47
  "undici": "^7.10.0"
48
48
  },
49
49
  "devDependencies": {
50
- "@biomejs/biome": "^1.9.4",
50
+ "@biomejs/biome": "^2.0.0",
51
51
  "@testcontainers/localstack": "^11.0.3",
52
52
  "@testcontainers/minio": "^11.0.3",
53
- "@types/node": "^24.0.1",
54
- "@typescript/native-preview": "^7.0.0-dev.20250613.1",
53
+ "@types/node": "^24.0.3",
54
+ "@typescript/native-preview": "^7.0.0-dev.20250618.1",
55
55
  "expect": "^30.0.0",
56
- "lefthook": "^1.11.13",
56
+ "lefthook": "^1.11.14",
57
57
  "tsup": "^8.5.0",
58
+ "tsx": "^4.20.3",
58
59
  "typedoc": "^0.28.5"
59
60
  },
60
61
  "engines": {
61
- "node": "^20.19.0 || ^22.14.0 || ^24.0.0"
62
+ "node": "^20.19.2 || ^22.16.0 || ^24.2.0"
62
63
  }
63
64
  }