lean-s3 0.1.3 → 0.1.5
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 +18 -18
- package/package.json +10 -9
- package/src/AmzDate.js +0 -2
- package/src/KeyCache.js +0 -2
- package/src/S3BucketEntry.js +91 -0
- package/src/S3Client.js +311 -87
- package/src/S3Error.js +0 -2
- package/src/S3File.js +0 -2
- package/src/S3Stat.js +0 -8
- package/src/index.d.ts +25 -0
- package/src/sign.js +49 -40
- package/src/url.js +0 -2
- package/src/test-common.js +0 -94
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# lean-s3
|
|
1
|
+
# 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
|
|
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
|
|
87
|
-
|
|
88
|
-
(
|
|
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
|
|
91
|
-
(
|
|
92
|
-
(
|
|
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
|
|
95
|
-
(
|
|
96
|
-
( 24.00 b … 1.
|
|
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
|
|
99
|
-
(
|
|
100
|
-
(
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
@@ -159,4 +159,4 @@ const client = new S3Client({
|
|
|
159
159
|
Popular S3 provider missing? Open an issue or file a PR!
|
|
160
160
|
|
|
161
161
|
[^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.
|
|
162
|
-
[^2]: `git clone git@github.com:nikeee/lean-s3.git && cd lean-s3
|
|
162
|
+
[^2]: `git clone git@github.com:nikeee/lean-s3.git && cd lean-s3 && npm ci && cd bench && npm ci && npm start`
|
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.
|
|
5
|
+
"version": "0.1.5",
|
|
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.
|
|
30
|
+
"@aws-sdk/client-s3": "^3.812.0",
|
|
31
31
|
"@biomejs/biome": "^1.9.4",
|
|
32
|
-
"@testcontainers/localstack": "^10.
|
|
33
|
-
"@testcontainers/minio": "^10.
|
|
34
|
-
"@types/node": "^22.
|
|
32
|
+
"@testcontainers/localstack": "^10.27.0",
|
|
33
|
+
"@testcontainers/minio": "^10.27.0",
|
|
34
|
+
"@types/node": "^22.15.20",
|
|
35
35
|
"expect": "^29.7.0",
|
|
36
|
-
"lefthook": "^1.11.
|
|
37
|
-
"typedoc": "^0.28.
|
|
36
|
+
"lefthook": "^1.11.13",
|
|
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
|
-
"
|
|
43
|
+
"fast-xml-parser": "^5.2.3",
|
|
44
|
+
"undici": "^7.10.0"
|
|
44
45
|
}
|
|
45
46
|
}
|
package/src/AmzDate.js
CHANGED
package/src/KeyCache.js
CHANGED
|
@@ -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
|
|
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
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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 (
|
|
650
|
+
if (response.headers["content-type"] === "application/xml") {
|
|
421
651
|
return response.body.text().then(body => {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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(
|
|
438
|
-
message: error
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
package/src/S3File.js
CHANGED
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
|
-
//
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
//
|
|
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(
|
|
78
|
-
|
|
79
|
-
|
|
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").
|
|
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
|
-
//
|
|
105
|
-
|
|
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
|
-
|
|
115
|
-
hash.update("\n");
|
|
116
|
+
canonData += `${header}:${sortedHeaders[header]}\n`;
|
|
116
117
|
}
|
|
118
|
+
canonData += "\n";
|
|
117
119
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
package/src/test-common.js
DELETED
|
@@ -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
|
-
}
|