lean-s3 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # lean-s3
1
+ # lean-s3 [![npm badge](https://img.shields.io/npm/v/lean-s3)](https://www.npmjs.com/package/lean-s3)
2
2
 
3
3
  A server-side S3 API for the regular user. lean-s3 tries to provide the 80% of S3 that most people use. It is heavily inspired by [Bun's S3 API](https://bun.sh/docs/api/s3). Requires a Node.js version that supports `fetch`.
4
4
 
@@ -61,7 +61,7 @@ pnpm add lean-s3
61
61
  ```
62
62
 
63
63
  ## Why?
64
- [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3) is cumbersome to use and doesn't align well with the current web standards. It is focused on providing a great experienced when used in conjunction with other AWS services. This comes at the cost of performance and package size:
64
+ [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3) is cumbersome to use and doesn't align well with the current web standards. It is focused on providing a great experience when used in conjunction with other AWS services. This comes at the cost of performance and package size:
65
65
 
66
66
  ```sh
67
67
  $ npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
@@ -83,27 +83,27 @@ lean-s3 is currently about 20x faster than AWS SDK when it comes to pre-signing
83
83
  ```
84
84
  benchmark avg (min … max) p75 / p99
85
85
  -------------------------------------------- ---------
86
- @aws-sdk/s3-request-presigner 184.32 µs/iter 183.38 µs
87
- (141.15 µs … 1.19 ms) 579.17 µs
88
- (312.00 b … 5.07 mb) 233.20 kb
86
+ @aws-sdk/s3-request-presigner 130.73 µs/iter 128.99 µs
87
+ (102.27 µs … 938.72 µs) 325.96 µs
88
+ (712.00 b … 5.85 mb) 228.48 kb
89
89
 
90
- lean-s3 8.48 µs/iter 8.21 µs
91
- (7.85 µs … 1.06 ms) 11.23 µs
92
- (128.00 b 614.83 kb) 5.26 kb
90
+ lean-s3 4.22 µs/iter 4.20 µs
91
+ (4.02 µs … 5.96 µs) 4.52 µs
92
+ ( 3.54 kb 3.54 kb) 3.54 kb
93
93
 
94
- aws4fetch 65.49 µs/iter 62.83 µs
95
- (52.43 µs … 1.01 ms) 158.99 µs
96
- ( 24.00 b … 1.42 mb) 53.38 kb
94
+ aws4fetch 52.41 µs/iter 50.71 µs
95
+ (36.06 µs … 1.79 ms) 173.15 µs
96
+ ( 24.00 b … 1.66 mb) 51.60 kb
97
97
 
98
- minio client 19.82 µs/iter 18.35 µs
99
- (17.28 µs … 1.61 ms) 34.41 µs
100
- (768.00 b … 721.07 kb) 16.18 kb
98
+ minio client 16.21 µs/iter 15.13 µs
99
+ (13.14 µs … 1.25 ms) 27.08 µs
100
+ (192.00 b … 1.43 mb) 16.02 kb
101
101
 
102
102
  summary
103
103
  lean-s3
104
- 2.34x faster than minio client
105
- 7.72x faster than aws4fetch
106
- 21.74x faster than @aws-sdk/s3-request-presigner
104
+ 3.84x faster than minio client
105
+ 12.42x faster than aws4fetch
106
+ 30.99x faster than @aws-sdk/s3-request-presigner
107
107
  ```
108
108
 
109
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.
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.1.3",
5
+ "version": "0.1.4",
6
6
  "description": "A server-side S3 API for the regular user.",
7
7
  "keywords": [
8
8
  "s3",
@@ -27,19 +27,20 @@
27
27
  "format": "biome format --write ./src && biome lint --write ./src && biome check --write ./src"
28
28
  },
29
29
  "devDependencies": {
30
- "@aws-sdk/client-s3": "^3.787.0",
30
+ "@aws-sdk/client-s3": "^3.802.0",
31
31
  "@biomejs/biome": "^1.9.4",
32
- "@testcontainers/localstack": "^10.24.2",
33
- "@testcontainers/minio": "^10.24.2",
34
- "@types/node": "^22.14.1",
32
+ "@testcontainers/localstack": "^10.25.0",
33
+ "@testcontainers/minio": "^10.25.0",
34
+ "@types/node": "^22.15.3",
35
35
  "expect": "^29.7.0",
36
- "lefthook": "^1.11.10",
37
- "typedoc": "^0.28.2"
36
+ "lefthook": "^1.11.12",
37
+ "typedoc": "^0.28.4"
38
38
  },
39
39
  "engines": {
40
- "node": "^20.19.0 || ^22.14.0"
40
+ "node": "^20.19.0 || ^22.14.0 || ^24.0.0"
41
41
  },
42
42
  "dependencies": {
43
+ "fast-xml-parser": "^5.2.1",
43
44
  "undici": "^7.8.0"
44
45
  }
45
46
  }
package/src/AmzDate.js CHANGED
@@ -1,5 +1,3 @@
1
- // @ts-check
2
-
3
1
  const ONE_DAY = 1000 * 60 * 60 * 24;
4
2
 
5
3
  /**
package/src/KeyCache.js CHANGED
@@ -1,5 +1,3 @@
1
- // @ts-check
2
-
3
1
  import * as sign from "./sign.js";
4
2
 
5
3
  /**@typedef {import("./AmzDate.js").AmzDate} AmzDate */
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @typedef {import("./index.js").StorageClass} StorageClass
3
+ * @typedef {import("./index.js").ChecksumAlgorithm} ChecksumAlgorithm
4
+ * @typedef {import("./index.js").ChecksumType} ChecksumType
5
+ */
6
+
7
+ /**
8
+ * @internal Normally, we'd use an interface for that, but having a class with pre-defined fields makes it easier for V8 top optimize hidden classes.
9
+ */
10
+ export default class S3BucketEntry {
11
+ /**
12
+ * @readonly
13
+ * @type {string}
14
+ */
15
+ key;
16
+ /**
17
+ * @readonly
18
+ * @type {number}
19
+ */
20
+ size;
21
+ /**
22
+ * @readonly
23
+ * @type {Date}
24
+ */
25
+ lastModified;
26
+ /**
27
+ * @readonly
28
+ * @type {string}
29
+ */
30
+ etag;
31
+ /**
32
+ * @readonly
33
+ * @type {StorageClass}
34
+ */
35
+ storageClass;
36
+ /**
37
+ * @readonly
38
+ * @type {ChecksumAlgorithm | undefined}
39
+ */
40
+ checksumAlgorithm;
41
+ /**
42
+ * @readonly
43
+ * @type {ChecksumType | undefined}
44
+ */
45
+ checksumType;
46
+
47
+ /**
48
+ * @param {string} key
49
+ * @param {number} size
50
+ * @param {Date} lastModified
51
+ * @param {string} etag
52
+ * @param {StorageClass} storageClass
53
+ * @param {ChecksumAlgorithm | undefined} checksumAlgorithm
54
+ * @param {ChecksumType | undefined} checksumType
55
+ */
56
+ constructor(
57
+ key,
58
+ size,
59
+ lastModified,
60
+ etag,
61
+ storageClass,
62
+ checksumAlgorithm,
63
+ checksumType,
64
+ ) {
65
+ this.key = key;
66
+ this.size = size;
67
+ this.lastModified = lastModified;
68
+ this.etag = etag;
69
+ this.storageClass = storageClass;
70
+ this.checksumAlgorithm = checksumAlgorithm;
71
+ this.checksumType = checksumType;
72
+ }
73
+
74
+ /**
75
+ * @internal
76
+ * @param {any} source
77
+ * @returns {S3BucketEntry}
78
+ */
79
+ static parse(source) {
80
+ // TODO: check values and throw exceptions
81
+ return new S3BucketEntry(
82
+ source.Key,
83
+ source.Size,
84
+ new Date(source.LastModified),
85
+ source.ETag,
86
+ source.StorageClass,
87
+ source.ChecksumAlgorithm,
88
+ source.ChecksumType,
89
+ );
90
+ }
91
+ }
package/src/S3Client.js CHANGED
@@ -1,9 +1,9 @@
1
- // @ts-check
2
-
3
1
  import { request, Dispatcher, Agent } from "undici";
2
+ import { XMLParser, XMLBuilder } from "fast-xml-parser";
4
3
 
5
4
  import S3File from "./S3File.js";
6
5
  import S3Error from "./S3Error.js";
6
+ import S3BucketEntry from "./S3BucketEntry.js";
7
7
  import KeyCache from "./KeyCache.js";
8
8
  import * as amzDate from "./AmzDate.js";
9
9
  import * as sign from "./sign.js";
@@ -16,6 +16,9 @@ import {
16
16
  export const write = Symbol("write");
17
17
  export const stream = Symbol("stream");
18
18
 
19
+ const xmlParser = new XMLParser();
20
+ const xmlBuilder = new XMLBuilder();
21
+
19
22
  /**
20
23
  * @typedef {import("./index.d.ts").S3ClientOptions} S3ClientOptions
21
24
  * @typedef {import("./index.d.ts").PresignableHttpMethod} PresignableHttpMethod
@@ -24,6 +27,7 @@ export const stream = Symbol("stream");
24
27
  * @typedef {import("./index.d.ts").S3FilePresignOptions} S3FilePresignOptions
25
28
  * @typedef {import("./index.d.ts").OverridableS3ClientOptions} OverridableS3ClientOptions
26
29
  * @typedef {import("./index.d.ts").CreateFileInstanceOptions} CreateFileInstanceOptions
30
+ * @typedef {import("./index.d.ts").ListObjectsResponse} ListObjectsResponse
27
31
  */
28
32
 
29
33
  /**
@@ -167,7 +171,7 @@ export default class S3Client {
167
171
  const dataDigest = sign.createCanonicalDataDigestHostOnly(
168
172
  method,
169
173
  res.pathname,
170
- query.toString(),
174
+ query,
171
175
  res.host,
172
176
  );
173
177
 
@@ -186,12 +190,245 @@ export default class S3Client {
186
190
  );
187
191
 
188
192
  // See `buildSearchParams` for casing on this parameter
189
- query.set("X-Amz-Signature", signature);
190
-
191
- res.search = query.toString();
193
+ res.search = `${query}&X-Amz-Signature=${signature}`;
192
194
  return res.toString();
193
195
  }
194
196
 
197
+ /**
198
+ *
199
+ * @param {readonly S3BucketEntry[] | readonly string[]} objects
200
+ * @param {*} options
201
+ */
202
+ async deleteObjects(objects, options) {
203
+ throw new Error("Not implemented");
204
+ }
205
+
206
+ //#region list
207
+
208
+ /**
209
+ * Uses `ListObjectsV2` to iterate over all keys. Pagination and continuation is handled internally.
210
+
211
+ * @param {{
212
+ * prefix?: string;
213
+ * startAfter?: string;
214
+ * signal?: AbortSignal;
215
+ * internalPageSize?: number;
216
+ * }} [options]
217
+ * @returns {AsyncGenerator<S3BucketEntry>}
218
+ */
219
+ async *listIterating(options) {
220
+ // only used to get smaller pages, so we can test this properly
221
+ const maxKeys = options?.internalPageSize ?? undefined;
222
+
223
+ let res = undefined;
224
+ let continuationToken = undefined;
225
+ do {
226
+ res = await this.list({
227
+ ...options,
228
+ maxKeys,
229
+ continuationToken,
230
+ });
231
+
232
+ if (!res || res.contents.length === 0) {
233
+ break;
234
+ }
235
+
236
+ yield* res.contents;
237
+
238
+ continuationToken = res.nextContinuationToken;
239
+ } while (continuationToken);
240
+ }
241
+
242
+ /**
243
+ *
244
+ * @param {{
245
+ * prefix?: string;
246
+ * maxKeys?: number;
247
+ * startAfter?: string;
248
+ * continuationToken?: string;
249
+ * signal?: AbortSignal;
250
+ * }} [options]
251
+ * @returns {Promise<ListObjectsResponse>}
252
+ */
253
+ async list(options = {}) {
254
+ // See `benchmark-operations.js` on why we don't use URLSearchParams but string concat
255
+ // tldr: This is faster and we know the params exactly, so we can focus our encoding
256
+
257
+ // ! minio requires these params to be in alphabetical order
258
+
259
+ let query = "";
260
+
261
+ if (typeof options.continuationToken !== "undefined") {
262
+ if (typeof options.continuationToken !== "string") {
263
+ throw new TypeError("`continuationToken` should be a `string`.");
264
+ }
265
+
266
+ query += `continuation-token=${encodeURIComponent(options.continuationToken)}&`;
267
+ }
268
+
269
+ query += "list-type=2";
270
+
271
+ if (typeof options.maxKeys !== "undefined") {
272
+ if (typeof options.maxKeys !== "number") {
273
+ throw new TypeError("`maxKeys` should be a `number`.");
274
+ }
275
+
276
+ query += `&max-keys=${options.maxKeys}`; // no encoding needed, it's a number
277
+ }
278
+
279
+ // TODO: delimiter?
280
+
281
+ // plan `if(a)` check, so empty strings will also not go into this branch, omitting the parameter
282
+ if (options.prefix) {
283
+ if (typeof options.prefix !== "string") {
284
+ throw new TypeError("`prefix` should be a `string`.");
285
+ }
286
+
287
+ query += `&prefix=${encodeURIComponent(options.prefix)}`;
288
+ }
289
+
290
+ if (typeof options.startAfter !== "undefined") {
291
+ if (typeof options.startAfter !== "string") {
292
+ throw new TypeError("`startAfter` should be a `string`.");
293
+ }
294
+
295
+ query += `&start-after=${encodeURIComponent(options.startAfter)}`;
296
+ }
297
+
298
+ const response = await this.#signedRequest(
299
+ "GET",
300
+ "",
301
+ query,
302
+ undefined,
303
+ undefined,
304
+ undefined,
305
+ undefined,
306
+ options.signal,
307
+ );
308
+
309
+ if (response.statusCode === 200) {
310
+ const text = await response.body.text();
311
+
312
+ let res = undefined;
313
+ try {
314
+ res = xmlParser.parse(text)?.ListBucketResult;
315
+ } catch (cause) {
316
+ // Possible according to AWS docs
317
+ throw new S3Error("Unknown", "", {
318
+ message: "S3 service responded with invalid XML.",
319
+ cause,
320
+ });
321
+ }
322
+
323
+ if (!res) {
324
+ throw new S3Error("Unknown", "", {
325
+ message: "Could not read bucket contents.",
326
+ });
327
+ }
328
+
329
+ // S3 is weird and doesn't return an array if there is only one item
330
+ const contents = Array.isArray(res.Contents)
331
+ ? (res.Contents?.map(S3BucketEntry.parse) ?? [])
332
+ : res.Contents
333
+ ? [res.Contents]
334
+ : [];
335
+
336
+ return {
337
+ name: res.Name,
338
+ prefix: res.Prefix,
339
+ startAfter: res.StartAfter,
340
+ isTruncated: res.IsTruncated,
341
+ continuationToken: res.ContinuationToken,
342
+ maxKeys: res.MaxKeys,
343
+ keyCount: res.KeyCount,
344
+ nextContinuationToken: res.NextContinuationToken,
345
+ contents,
346
+ };
347
+ }
348
+
349
+ // undici docs state that we shoul dump the body if not used
350
+ response.body.dump();
351
+ throw new Error(
352
+ `Response code not implemented yet: ${response.statusCode}`,
353
+ );
354
+ }
355
+
356
+ //#endregion
357
+
358
+ /**
359
+ * @param {import("./index.js").HttpMethod} method
360
+ * @param {string} pathWithoutBucket
361
+ * @param {string | undefined} query
362
+ * @param {import("./index.d.ts").UndiciBodyInit | undefined} body
363
+ * @param {Record<string, string>| undefined} additionalSignedHeaders
364
+ * @param {Record<string, string> | undefined} additionalUnsignedHeaders
365
+ * @param {Buffer | undefined} contentHash
366
+ * @param {AbortSignal | undefined} signal
367
+ */
368
+ async #signedRequest(
369
+ method,
370
+ pathWithoutBucket,
371
+ query,
372
+ body,
373
+ additionalSignedHeaders,
374
+ additionalUnsignedHeaders,
375
+ contentHash,
376
+ signal,
377
+ ) {
378
+ const bucket = this.#options.bucket;
379
+ const endpoint = this.#options.endpoint;
380
+ const region = this.#options.region;
381
+
382
+ const url = buildRequestUrl(endpoint, bucket, region, pathWithoutBucket);
383
+ if (query) {
384
+ url.search = query;
385
+ }
386
+
387
+ const now = amzDate.now();
388
+
389
+ const contentHashStr = contentHash?.toString("hex") ?? sign.unsignedPayload;
390
+
391
+ // Signed headers have to be sorted
392
+ // To enhance sorting, we're adding all possible values somehow pre-ordered
393
+ const headersToBeSigned = prepareHeadersForSigning({
394
+ host: url.host,
395
+ "x-amz-date": now.dateTime,
396
+ "x-amz-content-sha256": contentHashStr,
397
+ ...additionalSignedHeaders,
398
+ });
399
+
400
+ try {
401
+ return await request(url, {
402
+ method,
403
+ signal,
404
+ dispatcher: this.#dispatcher,
405
+ headers: {
406
+ ...headersToBeSigned,
407
+ authorization: this.#getAuthorizationHeader(
408
+ method,
409
+ url.pathname,
410
+ query ?? "",
411
+ now,
412
+ headersToBeSigned,
413
+ region,
414
+ contentHashStr,
415
+ this.#options.accessKeyId,
416
+ this.#options.secretAccessKey,
417
+ ),
418
+ ...additionalUnsignedHeaders,
419
+ "user-agent": "lean-s3",
420
+ },
421
+ body,
422
+ });
423
+ } catch (cause) {
424
+ signal?.throwIfAborted();
425
+ throw new S3Error("Unknown", pathWithoutBucket, {
426
+ message: "Unknown error during S3 request.",
427
+ cause,
428
+ });
429
+ }
430
+ }
431
+
195
432
  /**
196
433
  * @internal
197
434
  * @param {string} path
@@ -222,15 +459,16 @@ export default class S3Client {
222
459
 
223
460
  const now = amzDate.now();
224
461
 
462
+ const contentHashStr = contentHash?.toString("hex") ?? sign.unsignedPayload;
463
+
225
464
  // Signed headers have to be sorted
226
465
  // To enhance sorting, we're adding all possible values somehow pre-ordered
227
466
  const headersToBeSigned = prepareHeadersForSigning({
228
- "amz-sdk-invocation-id": crypto.randomUUID(),
229
467
  "content-length": contentLength?.toString() ?? undefined,
230
468
  "content-type": contentType,
231
469
  host: url.host,
232
470
  range: getRangeHeader(rageStart, rangeEndExclusive),
233
- "x-amz-content-sha256": contentHash?.toString("hex") ?? undefined,
471
+ "x-amz-content-sha256": contentHashStr,
234
472
  "x-amz-date": now.dateTime,
235
473
  });
236
474
 
@@ -250,11 +488,10 @@ export default class S3Client {
250
488
  now,
251
489
  headersToBeSigned,
252
490
  region,
253
- contentHash,
491
+ contentHashStr,
254
492
  this.#options.accessKeyId,
255
493
  this.#options.secretAccessKey,
256
494
  ),
257
- accept: "application/json", // So that we can parse errors as JSON instead of XML, if the server supports that
258
495
  "user-agent": "lean-s3",
259
496
  },
260
497
  body: data,
@@ -273,25 +510,6 @@ export default class S3Client {
273
510
  return;
274
511
  }
275
512
 
276
- const ct = response.headers["content-type"];
277
- if (ct === "application/json") {
278
- /** @type {any} */
279
- let error = undefined;
280
- try {
281
- error = await response.body.json();
282
- } catch (cause) {
283
- throw new S3Error("Unknown", path, {
284
- message: "Could not read response body.",
285
- cause,
286
- });
287
- }
288
- throw new S3Error(error?.Code ?? "Unknown", path, {
289
- message: error?.Message || undefined, // Message might be "",
290
- requestId: error?.RequestId || undefined, // RequestId might be ""
291
- hostId: error?.HostId || undefined, // HostId might be ""
292
- });
293
- }
294
-
295
513
  let body = undefined;
296
514
  try {
297
515
  body = await response.body.text();
@@ -301,12 +519,23 @@ export default class S3Client {
301
519
  cause,
302
520
  });
303
521
  }
304
- if (ct === "application/xml") {
305
- const error = tryProcessXMLError(body);
306
- throw new S3Error(error.code ?? "Unknown", path, {
307
- message: error.message || undefined, // Message might be "",
522
+
523
+ if (response.headers["content-type"] === "application/xml") {
524
+ let error = undefined;
525
+ try {
526
+ error = xmlParser.parse(body);
527
+ } catch (cause) {
528
+ throw new S3Error("Unknown", path, {
529
+ message: "Could not parse XML error response.",
530
+ cause,
531
+ });
532
+ }
533
+
534
+ throw new S3Error(error.Code || "Unknown", path, {
535
+ message: error.Message || undefined, // Message might be "",
308
536
  });
309
537
  }
538
+
310
539
  throw new S3Error("Unknown", path, {
311
540
  message: "Unknown error during S3 request.",
312
541
  });
@@ -331,14 +560,15 @@ export default class S3Client {
331
560
 
332
561
  const range = getRangeHeader(rageStart, rangeEndExclusive);
333
562
 
563
+ const contentHashStr = contentHash?.toString("hex") ?? sign.unsignedPayload;
564
+
334
565
  const headersToBeSigned = prepareHeadersForSigning({
335
566
  "amz-sdk-invocation-id": crypto.randomUUID(),
336
567
  // TODO: Maybe support retries and do "amz-sdk-request": attempt=1; max=3
337
568
  host: url.host,
338
569
  range,
339
570
  // Hetzner doesnt care if the x-amz-content-sha256 header is missing, R2 requires it to be present
340
- "x-amz-content-sha256":
341
- contentHash?.toString("hex") ?? "UNSIGNED-PAYLOAD",
571
+ "x-amz-content-sha256": contentHashStr,
342
572
  "x-amz-date": now.dateTime,
343
573
  });
344
574
 
@@ -369,7 +599,7 @@ export default class S3Client {
369
599
  now,
370
600
  headersToBeSigned,
371
601
  region,
372
- contentHash,
602
+ contentHashStr,
373
603
  this.#options.accessKeyId,
374
604
  this.#options.secretAccessKey,
375
605
  ),
@@ -417,26 +647,22 @@ export default class S3Client {
417
647
  const responseText = undefined;
418
648
  const ct = response.headers["content-type"];
419
649
 
420
- if (ct === "application/xml") {
650
+ if (response.headers["content-type"] === "application/xml") {
421
651
  return response.body.text().then(body => {
422
- const error = tryProcessXMLError(body);
423
- const code = error?.code || "Unknown"; // || instead of ??, so we coerce empty strings
424
- return controller.error(
425
- new S3Error(code, path, {
426
- message: error?.message || undefined, // || instead of ??, so we coerce empty strings
427
- cause: responseText,
428
- }),
429
- );
430
- }, onNetworkError);
431
- }
432
-
433
- if (typeof ct === "string" && ct.startsWith("application/json")) {
434
- return response.body.json().then((/** @type {any} */ error) => {
435
- const code = error?.code || "Unknown"; // || instead of ??, so we coerce empty strings
652
+ let error = undefined;
653
+ try {
654
+ error = xmlParser.parse(body);
655
+ } catch (cause) {
656
+ return controller.error(
657
+ new S3Error("Unknown", path, {
658
+ message: "Could not parse XML error response.",
659
+ cause,
660
+ }),
661
+ );
662
+ }
436
663
  return controller.error(
437
- new S3Error(code, path, {
438
- message: error?.message || undefined, // || instead of ??, so we coerce empty strings
439
- cause: responseText,
664
+ new S3Error(error.Code || "Unknown", path, {
665
+ message: error.Message || undefined, // Message might be "",
440
666
  }),
441
667
  );
442
668
  }, onNetworkError);
@@ -465,13 +691,13 @@ export default class S3Client {
465
691
  }
466
692
 
467
693
  /**
468
- * @param {PresignableHttpMethod} method
694
+ * @param {import("./index.js").HttpMethod} method
469
695
  * @param {string} path
470
696
  * @param {string} query
471
697
  * @param {amzDate.AmzDate} date
472
698
  * @param {Record<string, string>} sortedSignedHeaders
473
699
  * @param {string} region
474
- * @param {Buffer | undefined} contentHash
700
+ * @param {string} contentHashStr
475
701
  * @param {string} accessKeyId
476
702
  * @param {string} secretAccessKey
477
703
  */
@@ -482,7 +708,7 @@ export default class S3Client {
482
708
  date,
483
709
  sortedSignedHeaders,
484
710
  region,
485
- contentHash,
711
+ contentHashStr,
486
712
  accessKeyId,
487
713
  secretAccessKey,
488
714
  ) {
@@ -491,7 +717,7 @@ export default class S3Client {
491
717
  path,
492
718
  query,
493
719
  sortedSignedHeaders,
494
- contentHash?.toString("hex") ?? sign.unsignedPayload,
720
+ contentHashStr,
495
721
  );
496
722
 
497
723
  const signingKey = this.#keyCache.computeIfAbsent(
@@ -524,7 +750,7 @@ export default class S3Client {
524
750
  * @param {string | null | undefined} sessionToken
525
751
  * @param {Acl | null | undefined} acl
526
752
  * @param {string | null | undefined} contentHashStr
527
- * @returns
753
+ * @returns {string}
528
754
  */
529
755
  export function buildSearchParams(
530
756
  amzCredential,
@@ -539,38 +765,36 @@ export function buildSearchParams(
539
765
  // We tried to make these query params entirely lower-cased, just like the headers
540
766
  // but Cloudflare R2 requires them to have this exact casing
541
767
 
542
- const q = new URLSearchParams();
543
- q.set("X-Amz-Algorithm", "AWS4-HMAC-SHA256");
544
- q.set("X-Amz-Credential", amzCredential);
545
- q.set("X-Amz-Date", date.dateTime);
546
- q.set("X-Amz-Expires", expiresIn.toString());
547
- q.set("X-Amz-SignedHeaders", headerList);
768
+ // We didn't have any issues with them being in non-alphaetical order, but as some implementations decide to require sorting
769
+ // in non-pre-signed cases, we do it here as well
548
770
 
549
- if (contentHashStr) {
550
- q.set("X-Amz-Content-Sha256", contentHashStr);
771
+ // See `benchmark-operations.js` on why we don't use URLSearchParams but string concat
772
+
773
+ let res = "";
774
+
775
+ if (acl) {
776
+ res += `X-Amz-Acl=${encodeURIComponent(acl)}&`;
551
777
  }
552
- if (storageClass) {
553
- q.set("X-Amz-Storage-Class", storageClass);
778
+
779
+ res += "X-Amz-Algorithm=AWS4-HMAC-SHA256";
780
+
781
+ if (contentHashStr) {
782
+ // We assume that this is always hex-encoded, so no encoding needed
783
+ res += `&X-Amz-Content-Sha256=${contentHashStr}`;
554
784
  }
785
+
786
+ res += `&X-Amz-Credential=${encodeURIComponent(amzCredential)}`;
787
+ res += `&X-Amz-Date=${date.dateTime}`; // internal dateTimes don't need encoding
788
+ res += `&X-Amz-Expires=${expiresIn}`; // number -> no encoding
789
+
555
790
  if (sessionToken) {
556
- q.set("X-Amz-Security-Token", sessionToken);
791
+ res += `&X-Amz-Security-Token=${encodeURIComponent(sessionToken)}`;
557
792
  }
558
- if (acl) {
559
- q.set("X-Amz-Acl", acl);
560
- }
561
- return q;
562
- }
563
793
 
564
- const codePattern = /<Code>([a-zA-Z0-9\s-]+?)<\/Code>/g;
565
- const messagePattern = /<Message>([a-zA-Z0-9\s-\.]+?)<\/Message>/g;
566
- /**
567
- * @param {string} responseText May or may not be XML
568
- */
569
- function tryProcessXMLError(responseText) {
570
- // We don't have an XML parser in Node.js' std lib and we don't want to reference one for an optional diagnostic
571
- // So... :hide-the-pain-harold:
572
- return {
573
- code: codePattern.exec(responseText)?.[1] ?? undefined,
574
- message: messagePattern.exec(responseText)?.[1] ?? undefined,
575
- };
794
+ res += `&X-Amz-SignedHeaders=${encodeURIComponent(headerList)}`;
795
+
796
+ if (storageClass) {
797
+ res += `&X-Amz-Storage-Class=${storageClass}`;
798
+ }
799
+ return res;
576
800
  }
package/src/S3Error.js CHANGED
@@ -1,5 +1,3 @@
1
- // @ts-check
2
-
3
1
  export default class S3Error extends Error {
4
2
  /**
5
3
  * @type {string}
package/src/S3File.js CHANGED
@@ -1,5 +1,3 @@
1
- // @ts-check
2
-
3
1
  import S3Error from "./S3Error.js";
4
2
  import S3Stat from "./S3Stat.js";
5
3
  import { write, stream } from "./S3Client.js";
package/src/S3Stat.js CHANGED
@@ -1,7 +1,3 @@
1
- // @ts-check
2
-
3
- import { inspect } from "node:util";
4
-
5
1
  export default class S3Stat {
6
2
  /**
7
3
  * @type {string}
@@ -69,8 +65,4 @@ export default class S3Stat {
69
65
 
70
66
  return new S3Stat(etag, new Date(lm), size, ct);
71
67
  }
72
-
73
- toString() {
74
- return `S3Stats {\n\tlastModified: ${inspect(this.lastModified)},\n\tsize: ${inspect(this.size)},\n\ttype: ${inspect(this.type)},\n\tetag: ${inspect(this.etag)}\n}`;
75
- }
76
68
  }
package/src/index.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { Readable } from "node:stream";
2
2
 
3
+ import type S3BucketEntry from "./S3BucketEntry.js";
4
+
3
5
  export { default as S3File } from "./S3File.js";
4
6
  export { default as S3Client } from "./S3Client.js";
5
7
  export { default as S3Error } from "./S3Error.js";
6
8
  export { default as S3Stat } from "./S3Stat.js";
9
+ export { default as S3BucketEntry } from "./S3BucketEntry.js";
7
10
 
8
11
  export interface S3ClientOptions {
9
12
  bucket: string;
@@ -37,7 +40,17 @@ export type StorageClass =
37
40
  | "SNOW"
38
41
  | "STANDARD_IA";
39
42
 
43
+ export type ChecksumAlgorithm =
44
+ | "CRC32"
45
+ | "CRC32C"
46
+ | "CRC64NVME"
47
+ | "SHA1"
48
+ | "SHA256";
49
+
50
+ export type ChecksumType = "COMPOSITE" | "FULL_OBJECT";
51
+
40
52
  export type PresignableHttpMethod = "GET" | "DELETE" | "PUT" | "HEAD";
53
+ export type HttpMethod = PresignableHttpMethod | "POST"; // There are also others, but we don't want to support them yet
41
54
 
42
55
  export interface S3FilePresignOptions {
43
56
  contentHash: Buffer;
@@ -78,3 +91,15 @@ export type ByteSource = UndiciBodyInit | Blob;
78
91
  // | Response
79
92
  // | S3File
80
93
  // | ReadableStream<Uint8Array>
94
+
95
+ export type ListObjectsResponse = {
96
+ name: string;
97
+ prefix: string | undefined;
98
+ startAfter: string | undefined;
99
+ isTruncated: boolean;
100
+ continuationToken: string | undefined;
101
+ maxKeys: number;
102
+ keyCount: number;
103
+ nextContinuationToken: string | undefined;
104
+ contents: readonly S3BucketEntry[];
105
+ };
package/src/sign.js CHANGED
@@ -1,4 +1,3 @@
1
- // @ts-check
2
1
  import { createHmac, createHash } from "node:crypto";
3
2
 
4
3
  // Spec:
@@ -41,16 +40,12 @@ export function signCanonicalDataHash(
41
40
  date,
42
41
  region,
43
42
  ) {
44
- // TODO: Investigate if its actually faster than just concatenating the parts and do a single update()
43
+ // it is actually faster to pass a single large string instead of doing multiple .update() chains with the parameters
44
+ // see `benchmark-operations.js`
45
45
  return createHmac("sha256", signinKey)
46
- .update("AWS4-HMAC-SHA256\n")
47
- .update(date.dateTime)
48
- .update("\n")
49
- .update(date.date)
50
- .update("/")
51
- .update(region)
52
- .update("/s3/aws4_request\n")
53
- .update(canonicalDataHash)
46
+ .update(
47
+ `AWS4-HMAC-SHA256\n${date.dateTime}\n${date.date}/${region}/s3/aws4_request\n${canonicalDataHash}`,
48
+ )
54
49
  .digest("hex");
55
50
  }
56
51
 
@@ -63,8 +58,6 @@ export const unsignedPayload = "UNSIGNED-PAYLOAD";
63
58
  * Used for pre-signing only. Pre-signed URLs [cannot contain content hashes](https://github.com/aws/aws-sdk-js/blob/966fa6c316dbb11ca9277564ff7120e6b16467f4/lib/signers/v4.js#L182-L183)
64
59
  * and the only header that is signed is `host`. So we can use an optimized version for that.
65
60
  *
66
- * TODO: Maybe passing a contentHash is supported on GET in order to restrict access to a specific file
67
- *
68
61
  * @param {import("./index.js").PresignableHttpMethod} method
69
62
  * @param {string} path
70
63
  * @param {string} query
@@ -72,21 +65,18 @@ export const unsignedPayload = "UNSIGNED-PAYLOAD";
72
65
  * @returns
73
66
  */
74
67
  export function createCanonicalDataDigestHostOnly(method, path, query, host) {
75
- // TODO: Investigate if its actually faster than just concatenating the parts and do a single update()
68
+ // it is actually faster to pass a single large string instead of doing multiple .update() chains with the parameters
69
+ // see `benchmark-operations.js`
70
+
76
71
  return createHash("sha256")
77
- .update(method)
78
- .update("\n")
79
- .update(path)
80
- .update("\n")
81
- .update(query)
82
- .update("\nhost:")
83
- .update(host)
84
- .update("\n\nhost\nUNSIGNED-PAYLOAD")
72
+ .update(
73
+ `${method}\n${path}\n${query}\nhost:${host}\n\nhost\nUNSIGNED-PAYLOAD`,
74
+ )
85
75
  .digest("hex");
86
76
  }
87
77
 
88
78
  /**
89
- * @param {import("./index.js").PresignableHttpMethod} method
79
+ * @param {import("./index.js").HttpMethod} method
90
80
  * @param {string} path
91
81
  * @param {string} query
92
82
  * @param {Record<string, string>} sortedHeaders
@@ -100,31 +90,42 @@ export function createCanonicalDataDigest(
100
90
  sortedHeaders,
101
91
  contentHashStr,
102
92
  ) {
93
+ // Use this for debugging
94
+ /*
95
+ const xHash = {
96
+ h: createHash("sha256"),
97
+ m: "",
98
+ update(v) {
99
+ this.m += v;
100
+ this.h.update(v);
101
+ return this;
102
+ },
103
+ digest(v) {
104
+ if (this.m.includes("continuation-token")) console.log(this.m);
105
+ return this.h.digest(v);
106
+ },
107
+ };
108
+ */
109
+
103
110
  const sortedHeaderNames = Object.keys(sortedHeaders);
104
- // TODO: Investigate if its actually faster than just concatenating the parts and do a single update()
105
- const hash = createHash("sha256")
106
- .update(method)
107
- .update("\n")
108
- .update(path)
109
- .update("\n")
110
- .update(query)
111
- .update("\n");
111
+ // it is actually faster to pass a single large string instead of doing multiple .update() chains with the parameters
112
+ // see `benchmark-operations.js`
112
113
 
114
+ let canonData = `${method}\n${path}\n${query}\n`;
113
115
  for (const header of sortedHeaderNames) {
114
- hash.update(header).update(":").update(sortedHeaders[header]);
115
- hash.update("\n");
116
+ canonData += `${header}:${sortedHeaders[header]}\n`;
116
117
  }
118
+ canonData += "\n";
117
119
 
118
- hash.update("\n");
119
-
120
- for (let i = 0; i < sortedHeaderNames.length; ++i) {
121
- hash.update(sortedHeaderNames[i]);
122
- if (i < sortedHeaderNames.length - 1) {
123
- hash.update(";");
124
- }
120
+ // emit the first header without ";", so we can avoid branching inside the loop for the other headers
121
+ // this is just a version of `sortedHeaderList.join(";")` that seems about 2x faster (see `benchmark-operations.js`)
122
+ canonData += sortedHeaderNames.length > 0 ? sortedHeaderNames[0] : "";
123
+ for (let i = 1; i < sortedHeaderNames.length; ++i) {
124
+ canonData += `;${sortedHeaderNames[i]}`;
125
125
  }
126
+ canonData += `\n${contentHashStr}`;
126
127
 
127
- return hash.update("\n").update(contentHashStr).digest("hex");
128
+ return createHash("sha256").update(canonData).digest("hex");
128
129
  }
129
130
 
130
131
  /**
@@ -134,3 +135,11 @@ export function createCanonicalDataDigest(
134
135
  export function sha256(data) {
135
136
  return createHash("sha256").update(data).digest();
136
137
  }
138
+
139
+ /**
140
+ * @param {import("node:crypto").BinaryLike} data
141
+ * @returns {string}
142
+ */
143
+ export function md5Hex(data) {
144
+ return createHash("md5").update(data).digest("hex");
145
+ }
package/src/url.js CHANGED
@@ -1,5 +1,3 @@
1
- // @ts-check
2
-
3
1
  /**
4
2
  * @param {string} endpoint
5
3
  * @param {string} bucket
@@ -1,94 +0,0 @@
1
- /**
2
- * @module Used by integration tests and unit tests.
3
- */
4
-
5
- // @ts-check
6
- import { test } from "node:test";
7
- import { expect } from "expect";
8
-
9
- import { S3Client } from "./index.js";
10
-
11
- /**
12
- * @param {number} runId
13
- * @param {string} endpoint
14
- * @param {boolean} forcePathStyle
15
- * @param {string} accessKeyId
16
- * @param {string} secretAccessKey
17
- * @param {string} region
18
- * @param {string} bucket
19
- */
20
- export function runTests(
21
- runId,
22
- endpoint,
23
- forcePathStyle,
24
- accessKeyId,
25
- secretAccessKey,
26
- region,
27
- bucket,
28
- ) {
29
- const client = new S3Client({
30
- endpoint,
31
- accessKeyId,
32
- secretAccessKey,
33
- region,
34
- bucket,
35
- });
36
-
37
- test("presign-put", async () => {
38
- const testId = crypto.randomUUID();
39
- const expected = {
40
- hello: testId,
41
- };
42
-
43
- const url = client.presign(`${runId}/presign-test.json`, { method: "PUT" });
44
- const res = await fetch(url, {
45
- method: "PUT",
46
- body: JSON.stringify(expected),
47
- headers: {
48
- accept: "application/json",
49
- },
50
- });
51
- expect(res.ok).toBe(true);
52
-
53
- const f = client.file(`${runId}/presign-test.json`);
54
- try {
55
- const actual = await f.json();
56
- expect(actual).toStrictEqual(expected);
57
- } finally {
58
- await f.delete();
59
- }
60
- });
61
-
62
- test("roundtrip", async () => {
63
- const testId = crypto.randomUUID();
64
- const f = client.file(`${runId}/roundtrip.txt`);
65
- await f.write(testId);
66
- try {
67
- const stat = await f.stat();
68
- expect(stat).toEqual(
69
- expect.objectContaining({
70
- size: testId.length,
71
- type: "application/octet-stream",
72
- }),
73
- );
74
-
75
- const actual = await f.text();
76
- expect(actual).toStrictEqual(testId);
77
- } finally {
78
- await f.delete();
79
- }
80
- });
81
-
82
- test("slicing", async () => {
83
- const testId = crypto.randomUUID();
84
- const f = client.file(`${runId}/slicing.txt`);
85
- await f.write(testId);
86
- try {
87
- const slicedFile = f.slice(10, 20);
88
- const s = await slicedFile.text();
89
- expect(s).toEqual(testId.substring(10, 20));
90
- } finally {
91
- await f.delete();
92
- }
93
- });
94
- }