lean-s3 0.4.0 → 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 +7 -5
- package/dist/index.d.ts +107 -12
- package/dist/index.js +297 -104
- package/package.json +5 -5
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
|
|
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
|
|
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>} [
|
|
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,
|
|
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 [`
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
|
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>} [
|
|
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,
|
|
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 [`
|
|
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
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
636
|
+
void 0,
|
|
637
|
+
ensureValidBucketName(options.bucket ?? this.#options.bucket),
|
|
498
638
|
options.signal
|
|
499
639
|
);
|
|
500
|
-
if (response.statusCode
|
|
501
|
-
|
|
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.
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
//#
|
|
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
|
|
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
|
|
894
|
+
let deleteResult;
|
|
713
895
|
try {
|
|
714
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
|
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
|
|
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.4.
|
|
5
|
+
"version": "0.4.1",
|
|
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": "^
|
|
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.
|
|
54
|
-
"@typescript/native-preview": "^7.0.0-dev.
|
|
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.
|
|
56
|
+
"lefthook": "^1.11.14",
|
|
57
57
|
"tsup": "^8.5.0",
|
|
58
58
|
"tsx": "^4.20.3",
|
|
59
59
|
"typedoc": "^0.28.5"
|