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 +7 -5
- package/dist/index.d.ts +109 -13
- package/dist/index.js +312 -107
- package/package.json +6 -6
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
|
@@ -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>} [
|
|
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,
|
|
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 [`
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
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 =
|
|
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 [`
|
|
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
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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
|
-
|
|
648
|
+
void 0,
|
|
649
|
+
ensureValidBucketName(options.bucket ?? this.#options.bucket),
|
|
498
650
|
options.signal
|
|
499
651
|
);
|
|
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, "");
|
|
652
|
+
if (response.statusCode !== 200) {
|
|
653
|
+
throw await getResponseError(response, key);
|
|
526
654
|
}
|
|
527
|
-
response.body.
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
//#
|
|
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
|
|
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
|
|
906
|
+
let deleteResult;
|
|
713
907
|
try {
|
|
714
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
|
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
|
|
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.
|
|
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": "^
|
|
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.
|
|
55
|
-
"expect": "^30.0.
|
|
56
|
-
"lefthook": "^1.11.
|
|
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"
|