lean-s3 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,576 @@
1
+ // @ts-check
2
+
3
+ import { request, Dispatcher, Agent } from "undici";
4
+
5
+ import S3File from "./S3File.js";
6
+ import S3Error from "./S3Error.js";
7
+ import KeyCache from "./KeyCache.js";
8
+ import * as amzDate from "./AmzDate.js";
9
+ import * as sign from "./sign.js";
10
+ import {
11
+ buildRequestUrl,
12
+ getRangeHeader,
13
+ prepareHeadersForSigning,
14
+ } from "./url.js";
15
+
16
+ export const write = Symbol("write");
17
+ export const stream = Symbol("stream");
18
+
19
+ /**
20
+ * @typedef {import("./index.d.ts").S3ClientOptions} S3ClientOptions
21
+ * @typedef {import("./index.d.ts").PresignableHttpMethod} PresignableHttpMethod
22
+ * @typedef {import("./index.d.ts").StorageClass} StorageClass
23
+ * @typedef {import("./index.d.ts").Acl} Acl
24
+ * @typedef {import("./index.d.ts").S3FilePresignOptions} S3FilePresignOptions
25
+ * @typedef {import("./index.d.ts").OverridableS3ClientOptions} OverridableS3ClientOptions
26
+ * @typedef {import("./index.d.ts").CreateFileInstanceOptions} CreateFileInstanceOptions
27
+ */
28
+
29
+ /**
30
+ * A configured S3 bucket instance for managing files.
31
+ *
32
+ * @example
33
+ * ```js
34
+ * // Basic bucket setup
35
+ * const bucket = new S3Client({
36
+ * bucket: "my-bucket",
37
+ * accessKeyId: "key",
38
+ * secretAccessKey: "secret"
39
+ * });
40
+ * // Get file instance
41
+ * const file = bucket.file("image.jpg");
42
+ * await file.delete();
43
+ * ```
44
+ */
45
+ export default class S3Client {
46
+ /** @type {Readonly<S3ClientOptions>} */
47
+ #options;
48
+ #keyCache = new KeyCache();
49
+
50
+ // TODO: pass options to this in client
51
+ /** @type {Dispatcher} */
52
+ #dispatcher = new Agent();
53
+
54
+ /**
55
+ * Create a new instance of an S3 bucket so that credentials can be managed from a single instance instead of being passed to every method.
56
+ *
57
+ * @param {S3ClientOptions} options The default options to use for the S3 client.
58
+ */
59
+ constructor(options) {
60
+ if (!options) {
61
+ throw new Error("`options` is required.");
62
+ }
63
+
64
+ const {
65
+ accessKeyId,
66
+ secretAccessKey,
67
+ endpoint,
68
+ region,
69
+ bucket,
70
+ sessionToken,
71
+ } = options;
72
+
73
+ if (!accessKeyId || typeof accessKeyId !== "string") {
74
+ throw new Error("`accessKeyId` is required.");
75
+ }
76
+ if (!secretAccessKey || typeof secretAccessKey !== "string") {
77
+ throw new Error("`secretAccessKey` is required.");
78
+ }
79
+ if (!endpoint || typeof endpoint !== "string") {
80
+ throw new Error("`endpoint` is required.");
81
+ }
82
+ if (!region || typeof region !== "string") {
83
+ throw new Error("`region` is required.");
84
+ }
85
+ if (!bucket || typeof bucket !== "string") {
86
+ throw new Error("`bucket` is required.");
87
+ }
88
+
89
+ this.#options = {
90
+ accessKeyId,
91
+ secretAccessKey,
92
+ endpoint,
93
+ region,
94
+ bucket,
95
+ sessionToken,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Creates an S3File instance for the given path.
101
+ *
102
+ * @param {string} path
103
+ * @param {Partial<CreateFileInstanceOptions> | undefined} [options] TODO
104
+ * @returns {S3File}
105
+ * @example
106
+ * ```js
107
+ * const file = client.file("image.jpg");
108
+ * await file.write(imageData);
109
+ *
110
+ * const configFile = client.file("config.json", {
111
+ * type: "application/json",
112
+ * acl: "private"
113
+ * });
114
+ * ```
115
+ */
116
+ file(path, options) {
117
+ return new S3File(this, path, undefined, undefined, undefined);
118
+ }
119
+
120
+ /**
121
+ * Generate a presigned URL for temporary access to a file.
122
+ * Useful for generating upload/download URLs without exposing credentials.
123
+ * @param {string} path
124
+ * @param {Partial<S3FilePresignOptions & OverridableS3ClientOptions>} [signOptions]
125
+ * @returns {string} The operation on {@link S3Client#presign.path} as a pre-signed URL.
126
+ *
127
+ * @example
128
+ * ```js
129
+ * const downloadUrl = client.presign("file.pdf", {
130
+ * expiresIn: 3600 // 1 hour
131
+ * });
132
+ * ```
133
+ */
134
+ presign(
135
+ path,
136
+ {
137
+ method = "GET",
138
+ expiresIn = 3600, // TODO: Maybe rename this to expiresInSeconds
139
+ storageClass,
140
+ acl,
141
+ region: regionOverride,
142
+ bucket: bucketOverride,
143
+ endpoint: endpointOverride,
144
+ } = {},
145
+ ) {
146
+ const now = new Date();
147
+ const date = amzDate.getAmzDate(now);
148
+ const options = this.#options;
149
+
150
+ const region = regionOverride ?? options.region;
151
+ const bucket = bucketOverride ?? options.bucket;
152
+ const endpoint = endpointOverride ?? options.endpoint;
153
+
154
+ const res = buildRequestUrl(endpoint, bucket, region, path);
155
+
156
+ const query = buildSearchParams(
157
+ `${options.accessKeyId}/${date.date}/${region}/s3/aws4_request`,
158
+ date,
159
+ expiresIn,
160
+ "host",
161
+ undefined,
162
+ storageClass,
163
+ options.sessionToken,
164
+ acl,
165
+ );
166
+
167
+ const dataDigest = sign.createCanonicalDataDigestHostOnly(
168
+ method,
169
+ res.pathname,
170
+ query.toString(),
171
+ res.host,
172
+ );
173
+
174
+ const signingKey = this.#keyCache.computeIfAbsent(
175
+ date,
176
+ region,
177
+ options.accessKeyId,
178
+ options.secretAccessKey,
179
+ );
180
+
181
+ const signature = sign.signCanonicalDataHash(
182
+ signingKey,
183
+ dataDigest,
184
+ date,
185
+ region,
186
+ );
187
+
188
+ // See `buildSearchParams` for casing on this parameter
189
+ query.set("X-Amz-Signature", signature);
190
+
191
+ res.search = query.toString();
192
+ return res.toString();
193
+ }
194
+
195
+ /**
196
+ * @internal
197
+ * @param {string} path
198
+ * @param {import("./index.d.ts").UndiciBodyInit} data TODO
199
+ * @param {string} contentType
200
+ * @param {number | undefined} contentLength
201
+ * @param {Buffer | undefined} contentHash
202
+ * @param {number | undefined} rageStart
203
+ * @param {number | undefined} rangeEndExclusive
204
+ * @param {AbortSignal | undefined} signal
205
+ * @returns {Promise<void>}
206
+ */
207
+ async [write](
208
+ path,
209
+ data,
210
+ contentType,
211
+ contentLength,
212
+ contentHash,
213
+ rageStart,
214
+ rangeEndExclusive,
215
+ signal,
216
+ ) {
217
+ const bucket = this.#options.bucket;
218
+ const endpoint = this.#options.endpoint;
219
+ const region = this.#options.region;
220
+
221
+ const url = buildRequestUrl(endpoint, bucket, region, path);
222
+
223
+ const now = amzDate.now();
224
+
225
+ // Signed headers have to be sorted
226
+ // To enhance sorting, we're adding all possible values somehow pre-ordered
227
+ const headersToBeSigned = prepareHeadersForSigning({
228
+ "amz-sdk-invocation-id": crypto.randomUUID(),
229
+ "content-length": contentLength?.toString() ?? undefined,
230
+ "content-type": contentType,
231
+ host: url.host,
232
+ range: getRangeHeader(rageStart, rangeEndExclusive),
233
+ "x-amz-content-sha256": contentHash?.toString("hex") ?? undefined,
234
+ "x-amz-date": now.dateTime,
235
+ });
236
+
237
+ /** @type {import("undici").Dispatcher.ResponseData<unknown> | undefined} */
238
+ let response = undefined;
239
+ try {
240
+ response = await request(url, {
241
+ method: "PUT",
242
+ signal,
243
+ dispatcher: this.#dispatcher,
244
+ headers: {
245
+ ...headersToBeSigned,
246
+ authorization: this.#getAuthorizationHeader(
247
+ "PUT",
248
+ url.pathname,
249
+ url.search,
250
+ now,
251
+ headersToBeSigned,
252
+ region,
253
+ contentHash,
254
+ this.#options.accessKeyId,
255
+ this.#options.secretAccessKey,
256
+ ),
257
+ accept: "application/json", // So that we can parse errors as JSON instead of XML, if the server supports that
258
+ "user-agent": "lean-s3",
259
+ },
260
+ body: data,
261
+ });
262
+ } catch (cause) {
263
+ signal?.throwIfAborted();
264
+ throw new S3Error("Unknown", path, {
265
+ message: "Unknown error during S3 request.",
266
+ cause,
267
+ });
268
+ }
269
+
270
+ const status = response.statusCode;
271
+ if (200 <= status && status < 300) {
272
+ // everything seemed to work, no need to process response body
273
+ return;
274
+ }
275
+
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
+ let body = undefined;
296
+ try {
297
+ body = await response.body.text();
298
+ } catch (cause) {
299
+ throw new S3Error("Unknown", path, {
300
+ message: "Could not read response body.",
301
+ cause,
302
+ });
303
+ }
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 "",
308
+ });
309
+ }
310
+ throw new S3Error("Unknown", path, {
311
+ message: "Unknown error during S3 request.",
312
+ });
313
+ }
314
+
315
+ // TODO: Support abortSignal
316
+
317
+ /**
318
+ * @internal
319
+ * @param {string} path
320
+ * @param {Buffer | undefined} contentHash
321
+ * @param {number | undefined} rageStart
322
+ * @param {number | undefined} rangeEndExclusive
323
+ * @returns
324
+ */
325
+ [stream](path, contentHash, rageStart, rangeEndExclusive) {
326
+ const bucket = this.#options.bucket;
327
+ const endpoint = this.#options.endpoint;
328
+ const region = this.#options.region;
329
+ const now = amzDate.now();
330
+ const url = buildRequestUrl(endpoint, bucket, region, path);
331
+
332
+ const range = getRangeHeader(rageStart, rangeEndExclusive);
333
+
334
+ const headersToBeSigned = prepareHeadersForSigning({
335
+ "amz-sdk-invocation-id": crypto.randomUUID(),
336
+ // TODO: Maybe support retries and do "amz-sdk-request": attempt=1; max=3
337
+ host: url.host,
338
+ range,
339
+ // 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",
342
+ "x-amz-date": now.dateTime,
343
+ });
344
+
345
+ const ac = new AbortController();
346
+
347
+ return new ReadableStream({
348
+ type: "bytes",
349
+ start: controller => {
350
+ const onNetworkError = (/** @type {unknown} */ cause) => {
351
+ controller.error(
352
+ new S3Error("Unknown", path, {
353
+ message: undefined,
354
+ cause,
355
+ }),
356
+ );
357
+ };
358
+
359
+ request(url, {
360
+ method: "GET",
361
+ signal: ac.signal,
362
+ dispatcher: this.#dispatcher,
363
+ headers: {
364
+ ...headersToBeSigned,
365
+ authorization: this.#getAuthorizationHeader(
366
+ "GET",
367
+ url.pathname,
368
+ url.search,
369
+ now,
370
+ headersToBeSigned,
371
+ region,
372
+ contentHash,
373
+ this.#options.accessKeyId,
374
+ this.#options.secretAccessKey,
375
+ ),
376
+ "user-agent": "lean-s3",
377
+ },
378
+ }).then(response => {
379
+ const onData = controller.enqueue.bind(controller);
380
+ const onClose = controller.close.bind(controller);
381
+
382
+ const expectPartialResponse = range !== undefined;
383
+ const status = response.statusCode;
384
+ if (status === 200) {
385
+ if (expectPartialResponse) {
386
+ return controller.error(
387
+ new S3Error("Unknown", path, {
388
+ message: "Expected partial response to range request.",
389
+ }),
390
+ );
391
+ }
392
+
393
+ response.body.on("data", onData);
394
+ response.body.once("error", onNetworkError);
395
+ response.body.once("end", onClose);
396
+ return;
397
+ }
398
+
399
+ if (status === 206) {
400
+ if (!expectPartialResponse) {
401
+ return controller.error(
402
+ new S3Error("Unknown", path, {
403
+ message:
404
+ "Received partial response but expected a full response.",
405
+ }),
406
+ );
407
+ }
408
+
409
+ response.body.on("data", onData);
410
+ response.body.once("error", onNetworkError);
411
+ response.body.once("end", onClose);
412
+ return;
413
+ }
414
+
415
+ if (400 <= status && status < 500) {
416
+ // Some providers actually support JSON via "accept: application/json", but we cant rely on it
417
+ const responseText = undefined;
418
+ const ct = response.headers["content-type"];
419
+
420
+ if (ct === "application/xml") {
421
+ 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
436
+ return controller.error(
437
+ new S3Error(code, path, {
438
+ message: error?.message || undefined, // || instead of ??, so we coerce empty strings
439
+ cause: responseText,
440
+ }),
441
+ );
442
+ }, onNetworkError);
443
+ }
444
+
445
+ return controller.error(
446
+ new S3Error("Unknown", path, {
447
+ message: undefined,
448
+ cause: responseText,
449
+ }),
450
+ );
451
+ }
452
+
453
+ // TODO: Support other status codes
454
+ return controller.error(
455
+ new Error(
456
+ `Handling for status code ${status} not implemented yet. You might want to open an issue and describe your situation.`,
457
+ ),
458
+ );
459
+ }, onNetworkError);
460
+ },
461
+ cancel(reason) {
462
+ ac.abort(reason);
463
+ },
464
+ });
465
+ }
466
+
467
+ /**
468
+ * @param {PresignableHttpMethod} method
469
+ * @param {string} path
470
+ * @param {string} query
471
+ * @param {amzDate.AmzDate} date
472
+ * @param {Record<string, string>} sortedSignedHeaders
473
+ * @param {string} region
474
+ * @param {Buffer | undefined} contentHash
475
+ * @param {string} accessKeyId
476
+ * @param {string} secretAccessKey
477
+ */
478
+ #getAuthorizationHeader(
479
+ method,
480
+ path,
481
+ query,
482
+ date,
483
+ sortedSignedHeaders,
484
+ region,
485
+ contentHash,
486
+ accessKeyId,
487
+ secretAccessKey,
488
+ ) {
489
+ const dataDigest = sign.createCanonicalDataDigest(
490
+ method,
491
+ path,
492
+ query,
493
+ sortedSignedHeaders,
494
+ contentHash?.toString("hex") ?? sign.unsignedPayload,
495
+ );
496
+
497
+ const signingKey = this.#keyCache.computeIfAbsent(
498
+ date,
499
+ region,
500
+ accessKeyId,
501
+ secretAccessKey,
502
+ );
503
+
504
+ const signature = sign.signCanonicalDataHash(
505
+ signingKey,
506
+ dataDigest,
507
+ date,
508
+ region,
509
+ );
510
+
511
+ // no encodeURIComponent because because we assume that all headers don't need escaping
512
+ const signedHeadersSpec = Object.keys(sortedSignedHeaders).join(";");
513
+ const credentialSpec = `${accessKeyId}/${date.date}/${region}/s3/aws4_request`;
514
+ return `AWS4-HMAC-SHA256 Credential=${credentialSpec}, SignedHeaders=${signedHeadersSpec}, Signature=${signature}`;
515
+ }
516
+ }
517
+
518
+ /**
519
+ * @param {string} amzCredential
520
+ * @param {import("./AmzDate.js").AmzDate} date
521
+ * @param {number} expiresIn
522
+ * @param {string} headerList
523
+ * @param {StorageClass | null | undefined} storageClass
524
+ * @param {string | null | undefined} sessionToken
525
+ * @param {Acl | null | undefined} acl
526
+ * @param {string | null | undefined} contentHashStr
527
+ * @returns
528
+ */
529
+ export function buildSearchParams(
530
+ amzCredential,
531
+ date,
532
+ expiresIn,
533
+ headerList,
534
+ contentHashStr,
535
+ storageClass,
536
+ sessionToken,
537
+ acl,
538
+ ) {
539
+ // We tried to make these query params entirely lower-cased, just like the headers
540
+ // but Cloudflare R2 requires them to have this exact casing
541
+
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);
548
+
549
+ if (contentHashStr) {
550
+ q.set("X-Amz-Content-Sha256", contentHashStr);
551
+ }
552
+ if (storageClass) {
553
+ q.set("X-Amz-Storage-Class", storageClass);
554
+ }
555
+ if (sessionToken) {
556
+ q.set("X-Amz-Security-Token", sessionToken);
557
+ }
558
+ if (acl) {
559
+ q.set("X-Amz-Acl", acl);
560
+ }
561
+ return q;
562
+ }
563
+
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
+ };
576
+ }
package/src/S3Error.js ADDED
@@ -0,0 +1,55 @@
1
+ export default class S3Error extends Error {
2
+ /**
3
+ * @type {string}
4
+ * @readonly
5
+ */
6
+ code;
7
+ /**
8
+ * @type {string}
9
+ * @readonly
10
+ */
11
+ path;
12
+ /**
13
+ * @type {string | undefined}
14
+ * @readonly
15
+ */
16
+ message;
17
+ /**
18
+ * @type {string | undefined}
19
+ * @readonly
20
+ */
21
+ requestId;
22
+ /**
23
+ * @type {string | undefined}
24
+ * @readonly
25
+ */
26
+ hostId;
27
+
28
+ /**
29
+ * @param {string} code
30
+ * @param {string} path
31
+ * @param {{
32
+ * message?: string | undefined
33
+ * requestId?: string | undefined
34
+ * hostId?: string | undefined
35
+ * cause?: unknown
36
+ * }} options
37
+ */
38
+ constructor(
39
+ code,
40
+ path,
41
+ {
42
+ message = undefined,
43
+ requestId = undefined,
44
+ hostId = undefined,
45
+ cause = undefined,
46
+ } = {},
47
+ ) {
48
+ super(message, { cause });
49
+ this.code = code;
50
+ this.path = path;
51
+ this.message = message;
52
+ this.requestId = requestId;
53
+ this.hostId = hostId;
54
+ }
55
+ }