lean-s3 0.4.0 → 0.5.0

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
@@ -41,6 +41,7 @@ interface S3FilePresignOptions {
41
41
  /** Seconds. */
42
42
  expiresIn: number;
43
43
  method: PresignableHttpMethod;
44
+ contentLength: number;
44
45
  storageClass: StorageClass;
45
46
  acl: Acl;
46
47
  }
@@ -59,6 +60,75 @@ type ListObjectsIteratingOptions = {
59
60
  signal?: AbortSignal;
60
61
  internalPageSize?: number;
61
62
  };
63
+ type ListMultipartUploadsOptions = {
64
+ bucket?: string;
65
+ delimiter?: string;
66
+ keyMarker?: string;
67
+ maxUploads?: number;
68
+ prefix?: string;
69
+ uploadIdMarker?: string;
70
+ signal?: AbortSignal;
71
+ };
72
+ type ListMultipartUploadsResult = {
73
+ bucket?: string;
74
+ keyMarker?: string;
75
+ uploadIdMarker?: string;
76
+ nextKeyMarker?: string;
77
+ prefix?: string;
78
+ delimiter?: string;
79
+ nextUploadIdMarker?: string;
80
+ maxUploads?: number;
81
+ isTruncated?: boolean;
82
+ uploads: MultipartUpload[];
83
+ };
84
+ type MultipartUpload = {
85
+ checksumAlgorithm?: ChecksumAlgorithm;
86
+ checksumType?: ChecksumType;
87
+ initiated?: Date;
88
+ /**
89
+ * Key of the object for which the multipart upload was initiated.
90
+ * Length Constraints: Minimum length of 1.
91
+ */
92
+ key?: string;
93
+ storageClass?: StorageClass;
94
+ /**
95
+ * Upload ID identifying the multipart upload.
96
+ */
97
+ uploadId?: string;
98
+ };
99
+ type CreateMultipartUploadOptions = {
100
+ bucket?: string;
101
+ signal?: AbortSignal;
102
+ };
103
+ type CreateMultipartUploadResponse = {
104
+ bucket: string;
105
+ key: string;
106
+ uploadId: string;
107
+ };
108
+ type AbortMultipartUploadOptions = {
109
+ bucket?: string;
110
+ signal?: AbortSignal;
111
+ };
112
+ type CompleteMultipartUploadOptions = {
113
+ bucket?: string;
114
+ signal?: AbortSignal;
115
+ };
116
+ type CompleteMultipartUploadResult = {
117
+ location?: string;
118
+ bucket?: string;
119
+ key?: string;
120
+ etag?: string;
121
+ checksumCRC32?: string;
122
+ checksumCRC32C?: string;
123
+ checksumCRC64NVME?: string;
124
+ checksumSHA1?: string;
125
+ checksumSHA256?: string;
126
+ checksumType?: ChecksumType;
127
+ };
128
+ type MultipartUploadPart = {
129
+ partNumber: number;
130
+ etag: string;
131
+ };
62
132
  type ListObjectsResponse = {
63
133
  name: string;
64
134
  prefix: string | undefined;
@@ -129,7 +199,7 @@ declare class S3Client {
129
199
  *
130
200
  * lean-s3 does not enforce these restrictions.
131
201
  *
132
- * @param {Partial<CreateFileInstanceOptions>} [options] TODO
202
+ * @param {Partial<CreateFileInstanceOptions>} [_options] TODO
133
203
  * @example
134
204
  * ```js
135
205
  * const file = client.file("image.jpg");
@@ -141,7 +211,7 @@ declare class S3Client {
141
211
  * });
142
212
  * ```
143
213
  */
144
- file(path: string, options?: Partial<CreateFileInstanceOptions>): S3File;
214
+ file(path: string, _options?: Partial<CreateFileInstanceOptions>): S3File;
145
215
  /**
146
216
  * Generate a presigned URL for temporary access to a file.
147
217
  * Useful for generating upload/download URLs without exposing credentials.
@@ -155,18 +225,36 @@ declare class S3Client {
155
225
  * ```
156
226
  */
157
227
  presign(path: string, { method, expiresIn, // TODO: Maybe rename this to expiresInSeconds
158
- storageClass, acl, region: regionOverride, bucket: bucketOverride, endpoint: endpointOverride, }?: Partial<S3FilePresignOptions & OverridableS3ClientOptions>): string;
228
+ storageClass, contentLength, acl, region: regionOverride, bucket: bucketOverride, endpoint: endpointOverride, }?: Partial<S3FilePresignOptions & OverridableS3ClientOptions>): string;
229
+ createMultipartUpload(key: string, options?: CreateMultipartUploadOptions): Promise<CreateMultipartUploadResponse>;
159
230
  /**
160
- * Uses [`DeleteObjects`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) to delete multiple objects in a single request.
231
+ * @remarks Uses [`ListMultipartUploads`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListMultipartUploads.html).
232
+ * @throws {RangeError} If `options.maxKeys` is not between `1` and `1000`.
161
233
  */
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>;
234
+ listMultipartUploads(options?: ListMultipartUploadsOptions): Promise<ListMultipartUploadsResult>;
235
+ /**
236
+ * @remarks Uses [`AbortMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html).
237
+ * @throws {RangeError} If `key` is not at least 1 character long.
238
+ * @throws {Error} If `uploadId` is not provided.
239
+ */
240
+ abortMultipartUpload(key: string, uploadId: string, options?: AbortMultipartUploadOptions): Promise<void>;
241
+ /**
242
+ * @remarks Uses [`CompleteMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html).
243
+ * @throws {RangeError} If `key` is not at least 1 character long.
244
+ * @throws {Error} If `uploadId` is not provided.
245
+ */
246
+ completeMultipartUpload(key: string, uploadId: string, parts: readonly MultipartUploadPart[], options?: CompleteMultipartUploadOptions): Promise<{
247
+ location: any;
248
+ bucket: any;
249
+ key: any;
250
+ etag: any;
251
+ checksumCRC32: any;
252
+ checksumCRC32C: any;
253
+ checksumCRC64NVME: any;
254
+ checksumSHA1: any;
255
+ checksumSHA256: any;
256
+ checksumType: any;
257
+ }>;
170
258
  /**
171
259
  * Creates a new bucket on the S3 server.
172
260
  *
@@ -203,8 +291,16 @@ declare class S3Client {
203
291
  listIterating(options: ListObjectsIteratingOptions): AsyncGenerator<S3BucketEntry>;
204
292
  /**
205
293
  * Implements [`ListObjectsV2`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) to iterate over all keys.
294
+ *
295
+ * @throws {RangeError} If `maxKeys` is not between `1` and `1000`.
206
296
  */
207
297
  list(options?: ListObjectsOptions): Promise<ListObjectsResponse>;
298
+ /**
299
+ * Uses [`DeleteObjects`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) to delete multiple objects in a single request.
300
+ */
301
+ deleteObjects(objects: readonly S3BucketEntry[] | readonly string[], options?: DeleteObjectsOptions): Promise<{
302
+ errors: any;
303
+ } | null>;
208
304
  /**
209
305
  * Do not use this. This is an internal method.
210
306
  * TODO: Maybe move this into a separate free function?
@@ -338,4 +434,4 @@ type BucketInfo = {
338
434
  type?: string;
339
435
  };
340
436
 
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 };
437
+ 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
  /**
@@ -428,11 +431,17 @@ var S3Client = class {
428
431
  expiresIn = 3600,
429
432
  // TODO: Maybe rename this to expiresInSeconds
430
433
  storageClass,
434
+ contentLength,
431
435
  acl,
432
436
  region: regionOverride,
433
437
  bucket: bucketOverride,
434
438
  endpoint: endpointOverride
435
439
  } = {}) {
440
+ if (typeof contentLength === "number") {
441
+ if (contentLength < 0) {
442
+ throw new RangeError("`contentLength` must be >= 0.");
443
+ }
444
+ }
436
445
  const now2 = /* @__PURE__ */ new Date();
437
446
  const date = getAmzDate(now2);
438
447
  const options = this.#options;
@@ -444,13 +453,19 @@ var S3Client = class {
444
453
  `${options.accessKeyId}/${date.date}/${region}/s3/aws4_request`,
445
454
  date,
446
455
  expiresIn,
447
- "host",
448
- void 0,
456
+ typeof contentLength === "number" ? "content-length;host" : "host",
457
+ unsignedPayload,
449
458
  storageClass,
450
459
  options.sessionToken,
451
460
  acl
452
461
  );
453
- const dataDigest = createCanonicalDataDigestHostOnly(
462
+ const dataDigest = typeof contentLength === "number" ? createCanonicalDataDigest(
463
+ method,
464
+ res.pathname,
465
+ query,
466
+ { "content-length": String(contentLength), host: res.host },
467
+ unsignedPayload
468
+ ) : createCanonicalDataDigestHostOnly(
454
469
  method,
455
470
  res.pathname,
456
471
  query,
@@ -471,64 +486,189 @@ var S3Client = class {
471
486
  res.search = `${query}&X-Amz-Signature=${signature}`;
472
487
  return res.toString();
473
488
  }
489
+ //#region multipart uploads
490
+ async createMultipartUpload(key, options = {}) {
491
+ if (key.length < 1) {
492
+ throw new RangeError("`key` must be at least 1 character long.");
493
+ }
494
+ const response = await this[signedRequest](
495
+ "POST",
496
+ key,
497
+ "uploads=",
498
+ void 0,
499
+ void 0,
500
+ void 0,
501
+ void 0,
502
+ ensureValidBucketName(options.bucket ?? this.#options.bucket),
503
+ options.signal
504
+ );
505
+ if (response.statusCode !== 200) {
506
+ throw await getResponseError(response, key);
507
+ }
508
+ const text = await response.body.text();
509
+ const res = ensureParsedXml(text).InitiateMultipartUploadResult ?? {};
510
+ return {
511
+ bucket: res.Bucket,
512
+ key: res.Key,
513
+ uploadId: res.UploadId
514
+ };
515
+ }
474
516
  /**
475
- * Uses [`DeleteObjects`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) to delete multiple objects in a single request.
517
+ * @remarks Uses [`ListMultipartUploads`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListMultipartUploads.html).
518
+ * @throws {RangeError} If `options.maxKeys` is not between `1` and `1000`.
476
519
  */
477
- async deleteObjects(objects, options = {}) {
520
+ async listMultipartUploads(options = {}) {
521
+ const bucket = ensureValidBucketName(
522
+ options.bucket ?? this.#options.bucket
523
+ );
524
+ let query = "uploads=";
525
+ if (options.delimiter) {
526
+ if (typeof options.delimiter !== "string") {
527
+ throw new TypeError("`delimiter` should be a `string`.");
528
+ }
529
+ query += `&delimiter=${encodeURIComponent(options.delimiter)}`;
530
+ }
531
+ if (options.keyMarker) {
532
+ if (typeof options.keyMarker !== "string") {
533
+ throw new TypeError("`keyMarker` should be a `string`.");
534
+ }
535
+ query += `&key-marker=${encodeURIComponent(options.keyMarker)}`;
536
+ }
537
+ if (typeof options.maxUploads !== "undefined") {
538
+ if (typeof options.maxUploads !== "number") {
539
+ throw new TypeError("`maxUploads` should be a `number`.");
540
+ }
541
+ if (options.maxUploads < 1 || options.maxUploads > 1e3) {
542
+ throw new RangeError("`maxUploads` should be between 1 and 1000.");
543
+ }
544
+ query += `&max-uploads=${options.maxUploads}`;
545
+ }
546
+ if (options.prefix) {
547
+ if (typeof options.prefix !== "string") {
548
+ throw new TypeError("`prefix` should be a `string`.");
549
+ }
550
+ query += `&prefix=${encodeURIComponent(options.prefix)}`;
551
+ }
552
+ const response = await this[signedRequest](
553
+ "GET",
554
+ "",
555
+ query,
556
+ void 0,
557
+ void 0,
558
+ void 0,
559
+ void 0,
560
+ bucket,
561
+ options.signal
562
+ );
563
+ if (response.statusCode !== 200) {
564
+ throw await getResponseError(response, "");
565
+ }
566
+ const text = await response.body.text();
567
+ const root = ensureParsedXml(text).ListMultipartUploadsResult ?? {};
568
+ return {
569
+ bucket: root.Bucket || void 0,
570
+ delimiter: root.Delimiter || void 0,
571
+ prefix: root.Prefix || void 0,
572
+ keyMarker: root.KeyMarker || void 0,
573
+ uploadIdMarker: root.UploadIdMarker || void 0,
574
+ nextKeyMarker: root.NextKeyMarker || void 0,
575
+ nextUploadIdMarker: root.NextUploadIdMarker || void 0,
576
+ maxUploads: root.MaxUploads ?? 1e3,
577
+ // not using || to not override 0; caution: minio supports 10000(!)
578
+ isTruncated: root.IsTruncated === "true",
579
+ uploads: root.Upload?.map(
580
+ // biome-ignore lint/suspicious/noExplicitAny: we're parsing here
581
+ (u) => ({
582
+ key: u.Key || void 0,
583
+ uploadId: u.UploadId || void 0,
584
+ // TODO: Initiator
585
+ // TODO: Owner
586
+ storageClass: u.StorageClass || void 0,
587
+ checksumAlgorithm: u.ChecksumAlgorithm || void 0,
588
+ checksumType: u.ChecksumType || void 0,
589
+ initiated: u.Initiated ? new Date(u.Initiated) : void 0
590
+ })
591
+ ) ?? []
592
+ };
593
+ }
594
+ /**
595
+ * @remarks Uses [`AbortMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html).
596
+ * @throws {RangeError} If `key` is not at least 1 character long.
597
+ * @throws {Error} If `uploadId` is not provided.
598
+ */
599
+ async abortMultipartUpload(key, uploadId, options = {}) {
600
+ if (key.length < 1) {
601
+ throw new RangeError("`key` must be at least 1 character long.");
602
+ }
603
+ if (!uploadId) {
604
+ throw new Error("`uploadId` is required.");
605
+ }
606
+ const response = await this[signedRequest](
607
+ "DELETE",
608
+ key,
609
+ `uploadId=${encodeURIComponent(uploadId)}`,
610
+ void 0,
611
+ void 0,
612
+ void 0,
613
+ void 0,
614
+ ensureValidBucketName(options.bucket ?? this.#options.bucket),
615
+ options.signal
616
+ );
617
+ if (response.statusCode !== 204) {
618
+ throw await getResponseError(response, key);
619
+ }
620
+ }
621
+ /**
622
+ * @remarks Uses [`CompleteMultipartUpload`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CompleteMultipartUpload.html).
623
+ * @throws {RangeError} If `key` is not at least 1 character long.
624
+ * @throws {Error} If `uploadId` is not provided.
625
+ */
626
+ async completeMultipartUpload(key, uploadId, parts, options = {}) {
627
+ if (key.length < 1) {
628
+ throw new RangeError("`key` must be at least 1 character long.");
629
+ }
630
+ if (!uploadId) {
631
+ throw new Error("`uploadId` is required.");
632
+ }
478
633
  const body = xmlBuilder.build({
479
- Delete: {
480
- Quiet: true,
481
- Object: objects.map((o) => ({
482
- Key: typeof o === "string" ? o : o.key
634
+ CompleteMultipartUpload: {
635
+ Part: parts.map((part) => ({
636
+ PartNumber: part.partNumber,
637
+ ETag: part.etag
483
638
  }))
484
639
  }
485
640
  });
486
641
  const response = await this[signedRequest](
487
642
  "POST",
488
- "",
489
- "delete=",
490
- // "=" is needed by minio for some reason
643
+ key,
644
+ `uploadId=${encodeURIComponent(uploadId)}`,
491
645
  body,
492
- {
493
- "content-md5": md5Base64(body)
494
- },
495
646
  void 0,
496
647
  void 0,
497
- this.#options.bucket,
648
+ void 0,
649
+ ensureValidBucketName(options.bucket ?? this.#options.bucket),
498
650
  options.signal
499
651
  );
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, "");
652
+ if (response.statusCode !== 200) {
653
+ throw await getResponseError(response, key);
526
654
  }
527
- response.body.dump();
528
- throw new Error(
529
- `Response code not implemented yet: ${response.statusCode}`
530
- );
655
+ const text = await response.body.text();
656
+ const res = ensureParsedXml(text).CompleteMultipartUploadResult ?? {};
657
+ return {
658
+ location: res.Location || void 0,
659
+ bucket: res.Bucket || void 0,
660
+ key: res.Key || void 0,
661
+ etag: res.ETag || void 0,
662
+ checksumCRC32: res.ChecksumCRC32 || void 0,
663
+ checksumCRC32C: res.ChecksumCRC32C || void 0,
664
+ checksumCRC64NVME: res.ChecksumCRC64NVME || void 0,
665
+ checksumSHA1: res.ChecksumSHA1 || void 0,
666
+ checksumSHA256: res.ChecksumSHA256 || void 0,
667
+ checksumType: res.ChecksumType || void 0
668
+ };
531
669
  }
670
+ //#endregion
671
+ //#region bucket operations
532
672
  /**
533
673
  * Creates a new bucket on the S3 server.
534
674
  *
@@ -544,8 +684,7 @@ var S3Client = class {
544
684
  * @remarks Uses [`CreateBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html)
545
685
  */
546
686
  async createBucket(name, options) {
547
- ensureValidBucketName(name);
548
- let body = void 0;
687
+ let body;
549
688
  if (options) {
550
689
  const location = options.location && (options.location.name || options.location.type) ? {
551
690
  Name: options.location.name ?? void 0,
@@ -573,7 +712,7 @@ var S3Client = class {
573
712
  additionalSignedHeaders,
574
713
  void 0,
575
714
  void 0,
576
- name,
715
+ ensureValidBucketName(name),
577
716
  options?.signal
578
717
  );
579
718
  if (400 <= response.statusCode && response.statusCode < 500) {
@@ -593,7 +732,6 @@ var S3Client = class {
593
732
  * @remarks Uses [`DeleteBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html).
594
733
  */
595
734
  async deleteBucket(name, options) {
596
- ensureValidBucketName(name);
597
735
  const response = await this[signedRequest](
598
736
  "DELETE",
599
737
  "",
@@ -602,7 +740,7 @@ var S3Client = class {
602
740
  void 0,
603
741
  void 0,
604
742
  void 0,
605
- name,
743
+ ensureValidBucketName(name),
606
744
  options?.signal
607
745
  );
608
746
  if (400 <= response.statusCode && response.statusCode < 500) {
@@ -621,7 +759,6 @@ var S3Client = class {
621
759
  * @remarks Uses [`HeadBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html).
622
760
  */
623
761
  async bucketExists(name, options) {
624
- ensureValidBucketName(name);
625
762
  const response = await this[signedRequest](
626
763
  "HEAD",
627
764
  "",
@@ -630,7 +767,7 @@ var S3Client = class {
630
767
  void 0,
631
768
  void 0,
632
769
  void 0,
633
- name,
770
+ ensureValidBucketName(name),
634
771
  options?.signal
635
772
  );
636
773
  if (response.statusCode !== 404 && 400 <= response.statusCode && response.statusCode < 500) {
@@ -645,16 +782,16 @@ var S3Client = class {
645
782
  }
646
783
  throw new Error(`Response code not supported: ${response.statusCode}`);
647
784
  }
648
- //#region list
785
+ //#endregion
786
+ //#region list objects
649
787
  /**
650
788
  * 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
789
  */
652
790
  async *listIterating(options) {
653
791
  const maxKeys = options?.internalPageSize ?? void 0;
654
- let res = void 0;
655
- let continuationToken = void 0;
792
+ let continuationToken;
656
793
  do {
657
- res = await this.list({
794
+ const res = await this.list({
658
795
  ...options,
659
796
  maxKeys,
660
797
  continuationToken
@@ -668,6 +805,8 @@ var S3Client = class {
668
805
  }
669
806
  /**
670
807
  * Implements [`ListObjectsV2`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) to iterate over all keys.
808
+ *
809
+ * @throws {RangeError} If `maxKeys` is not between `1` and `1000`.
671
810
  */
672
811
  async list(options = {}) {
673
812
  let query = "";
@@ -682,6 +821,9 @@ var S3Client = class {
682
821
  if (typeof options.maxKeys !== "number") {
683
822
  throw new TypeError("`maxKeys` should be a `number`.");
684
823
  }
824
+ if (options.maxKeys < 1 || options.maxKeys > 1e3) {
825
+ throw new RangeError("`maxKeys` should be between 1 and 1000.");
826
+ }
685
827
  query += `&max-keys=${options.maxKeys}`;
686
828
  }
687
829
  if (options.prefix) {
@@ -707,41 +849,88 @@ var S3Client = class {
707
849
  options.bucket ?? this.#options.bucket,
708
850
  options.signal
709
851
  );
852
+ if (response.statusCode !== 200) {
853
+ response.body.dump();
854
+ throw new Error(
855
+ `Response code not implemented yet: ${response.statusCode}`
856
+ );
857
+ }
858
+ const text = await response.body.text();
859
+ const res = ensureParsedXml(text).ListBucketResult ?? {};
860
+ if (!res) {
861
+ throw new S3Error("Unknown", "", {
862
+ message: "Could not read bucket contents."
863
+ });
864
+ }
865
+ return {
866
+ name: res.Name,
867
+ prefix: res.Prefix,
868
+ startAfter: res.StartAfter,
869
+ isTruncated: res.IsTruncated,
870
+ continuationToken: res.ContinuationToken,
871
+ maxKeys: res.MaxKeys,
872
+ keyCount: res.KeyCount,
873
+ nextContinuationToken: res.NextContinuationToken,
874
+ contents: res.Contents?.map(S3BucketEntry.parse) ?? []
875
+ };
876
+ }
877
+ //#endregion
878
+ /**
879
+ * Uses [`DeleteObjects`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) to delete multiple objects in a single request.
880
+ */
881
+ async deleteObjects(objects, options = {}) {
882
+ const body = xmlBuilder.build({
883
+ Delete: {
884
+ Quiet: true,
885
+ Object: objects.map((o) => ({
886
+ Key: typeof o === "string" ? o : o.key
887
+ }))
888
+ }
889
+ });
890
+ const response = await this[signedRequest](
891
+ "POST",
892
+ "",
893
+ "delete=",
894
+ // "=" is needed by minio for some reason
895
+ body,
896
+ {
897
+ "content-md5": md5Base64(body)
898
+ },
899
+ void 0,
900
+ void 0,
901
+ this.#options.bucket,
902
+ options.signal
903
+ );
710
904
  if (response.statusCode === 200) {
711
905
  const text = await response.body.text();
712
- let res = void 0;
906
+ let deleteResult;
713
907
  try {
714
- res = xmlParser2.parse(text)?.ListBucketResult;
908
+ deleteResult = ensureParsedXml(text).DeleteResult ?? {};
715
909
  } catch (cause) {
716
910
  throw new S3Error("Unknown", "", {
717
911
  message: "S3 service responded with invalid XML.",
718
912
  cause
719
913
  });
720
914
  }
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
- };
915
+ const errors = (
916
+ // biome-ignore lint/suspicious/noExplicitAny: parsing
917
+ deleteResult.Error?.map((e) => ({
918
+ code: e.Code,
919
+ key: e.Key,
920
+ message: e.Message,
921
+ versionId: e.VersionId
922
+ })) ?? []
923
+ );
924
+ return errors.length > 0 ? { errors } : null;
925
+ }
926
+ if (400 <= response.statusCode && response.statusCode < 500) {
927
+ throw await getResponseError(response, "");
738
928
  }
739
929
  response.body.dump();
740
930
  throw new Error(
741
931
  `Response code not implemented yet: ${response.statusCode}`
742
932
  );
743
933
  }
744
- //#endregion
745
934
  /**
746
935
  * Do not use this. This is an internal method.
747
936
  * TODO: Maybe move this into a separate free function?
@@ -819,7 +1008,7 @@ var S3Client = class {
819
1008
  "x-amz-content-sha256": contentHashStr,
820
1009
  "x-amz-date": now2.dateTime
821
1010
  });
822
- let response = void 0;
1011
+ let response;
823
1012
  try {
824
1013
  response = await request(url, {
825
1014
  method: "PUT",
@@ -942,10 +1131,9 @@ var S3Client = class {
942
1131
  }
943
1132
  if (400 <= status && status < 500) {
944
1133
  const responseText = void 0;
945
- const ct = response.headers["content-type"];
946
1134
  if (response.headers["content-type"] === "application/xml") {
947
1135
  return response.body.text().then((body) => {
948
- let error = void 0;
1136
+ let error;
949
1137
  try {
950
1138
  error = xmlParser2.parse(body);
951
1139
  } catch (cause) {
@@ -1020,6 +1208,23 @@ function ensureValidBucketName(name) {
1020
1208
  if (name.includes("..")) {
1021
1209
  throw new Error("`name` must not contain two adjacent periods (..)");
1022
1210
  }
1211
+ return name;
1212
+ }
1213
+ function ensureParsedXml(text) {
1214
+ try {
1215
+ const r = xmlParser2.parse(text);
1216
+ if (!r) {
1217
+ throw new S3Error("Unknown", "", {
1218
+ message: "S3 service responded with empty XML."
1219
+ });
1220
+ }
1221
+ return r;
1222
+ } catch (cause) {
1223
+ throw new S3Error("Unknown", "", {
1224
+ message: "S3 service responded with invalid XML.",
1225
+ cause
1226
+ });
1227
+ }
1023
1228
  }
1024
1229
 
1025
1230
  // 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.4.0",
5
+ "version": "0.5.0",
6
6
  "description": "A server-side S3 API for the regular user.",
7
7
  "keywords": [
8
8
  "s3",
@@ -47,13 +47,13 @@
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",
55
- "expect": "^30.0.0",
56
- "lefthook": "^1.11.13",
53
+ "@types/node": "^24.0.3",
54
+ "@typescript/native-preview": "^7.0.0-dev.20250619.1",
55
+ "expect": "^30.0.2",
56
+ "lefthook": "^1.11.14",
57
57
  "tsup": "^8.5.0",
58
58
  "tsx": "^4.20.3",
59
59
  "typedoc": "^0.28.5"