@usehenri/s3 0.0.0 → 1.2.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/src/client.js ADDED
@@ -0,0 +1,552 @@
1
+ /**
2
+ * The five requests this package makes, and nothing else.
3
+ *
4
+ * `PUT`, `GET`, `HEAD` and `DELETE` on one object, plus a presigned `GET` a
5
+ * browser makes on its own. They go out through `node:http`/`node:https`
6
+ * rather than `fetch`, for three reasons that are all about the upload:
7
+ *
8
+ * - **`Content-Length`.** S3 refuses a `PUT` without one (a chunked body
9
+ * needs `aws-chunked`, which is a different signature entirely), and
10
+ * `Content-Length` is a forbidden header name in the Fetch specification:
11
+ * undici sets it itself for a buffer and uses chunked encoding for a
12
+ * stream. `http.request` takes the header that was asked for.
13
+ * - **Streaming, both ways.** A part is on the disk before anything decided
14
+ * to keep it, so an upload is a file stream and a download is a response
15
+ * stream; neither is ever held in memory, whatever `maxFileSize` says.
16
+ * - **The socket.** A timeout that means "idle", not "total", is what a
17
+ * large upload needs, and it is `setTimeout` on the request.
18
+ *
19
+ * `@usehenri/webhooks` reaches for the same two modules for the same kind of
20
+ * reason (it pins the socket to an address it checked), so this is the
21
+ * pattern of the repository rather than an exception to it.
22
+ */
23
+ const fs = require('node:fs');
24
+ const http = require('node:http');
25
+ const https = require('node:https');
26
+ const debug = require('debug')('henri:s3');
27
+
28
+ const { EMPTY, presign, sha256, sign } = require('./signature');
29
+ const { coded } = require('./errors');
30
+
31
+ /** How long a socket may say nothing before the attempt is abandoned (ms) */
32
+ const TIMEOUT = 30000;
33
+
34
+ /** How many times a request that failed for a passing reason is made again */
35
+ const RETRIES = 2;
36
+
37
+ /** How long the first wait between two attempts is (ms) */
38
+ const BACKOFF = 200;
39
+
40
+ /** How much of an error body is read before the rest is thrown away */
41
+ const ERROR_BODY = 8192;
42
+
43
+ /** The statuses worth making the same request again for */
44
+ const RETRYABLE = new Set([429, 500, 502, 503, 504]);
45
+
46
+ /** Where an object store lives when the application named no endpoint */
47
+ const AWS = 's3.{region}.amazonaws.com';
48
+
49
+ /**
50
+ * What a bucket may be called.
51
+ *
52
+ * The rules of a DNS-compatible bucket name, which is also the only kind
53
+ * that can be put in a host: lowercase letters, digits, dots and hyphens,
54
+ * three to sixty-three characters, starting and ending with a letter or a
55
+ * digit. A name is refused here rather than encoded into a url, because a
56
+ * bucket name reaching a host or a path is the one part of an S3 request an
57
+ * application controls.
58
+ */
59
+ const BUCKET = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/u;
60
+
61
+ /**
62
+ * Reads a response body to the end, up to a bound, and throws it away past
63
+ * it
64
+ *
65
+ * @param {http.IncomingMessage} response the response
66
+ * @param {number} [cap=ERROR_BODY] how many bytes to keep
67
+ * @returns {Promise<string>} what was read
68
+ */
69
+ function body(response, cap = ERROR_BODY) {
70
+ return new Promise((resolve) => {
71
+ const chunks = [];
72
+ let seen = 0;
73
+
74
+ response.on('data', (chunk) => {
75
+ if (seen < cap) {
76
+ chunks.push(chunk.subarray(0, cap - seen));
77
+ seen += chunk.length;
78
+ }
79
+ });
80
+ response.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
81
+ response.on('error', () => resolve(Buffer.concat(chunks).toString('utf8')));
82
+ response.resume();
83
+ });
84
+ }
85
+
86
+ /**
87
+ * What an S3 error document says, without parsing XML.
88
+ *
89
+ * The envelope is `<Error><Code>NoSuchKey</Code><Message>...</Message></Error>`
90
+ * on every implementation of the API, and two tags are all that is wanted:
91
+ * an XML parser to read a diagnostic would be a dependency bought for an
92
+ * error path.
93
+ *
94
+ * @param {string} text the response body
95
+ * @returns {{code: ?string, message: ?string}} what it said
96
+ */
97
+ function reason(text) {
98
+ const code = /<Code>([^<]{1,120})<\/Code>/u.exec(text || '');
99
+ const message = /<Message>([^<]{1,400})<\/Message>/u.exec(text || '');
100
+
101
+ return { code: code && code[1], message: message && message[1] };
102
+ }
103
+
104
+ /**
105
+ * A client for one bucket
106
+ *
107
+ * @class S3Client
108
+ */
109
+ class S3Client {
110
+ /**
111
+ * Creates an instance of S3Client.
112
+ *
113
+ * @param {object} [options={}] the storage block of the configuration
114
+ * @memberof S3Client
115
+ */
116
+ constructor(options = {}) {
117
+ const endpoint = this.endpointOf(options);
118
+
119
+ this.bucket = String(options.bucket || '');
120
+ this.region = String(options.region || 'us-east-1');
121
+ this.protocol = endpoint.protocol;
122
+ this.host = endpoint.host;
123
+ this.port = endpoint.port;
124
+ this.timeout =
125
+ Number(options.timeout) > 0 ? Number(options.timeout) : TIMEOUT;
126
+ this.retries = Number.isInteger(options.retries)
127
+ ? options.retries
128
+ : RETRIES;
129
+ this.credentials = {
130
+ accessKeyId: options.accessKeyId || '',
131
+ secretAccessKey: options.secretAccessKey || '',
132
+ sessionToken: options.sessionToken || null,
133
+ };
134
+
135
+ // Path style is what every S3-compatible store speaks and what AWS
136
+ // still answers; virtual-host style is what AWS prefers and what a
137
+ // custom domain (an R2 bucket behind one) needs. The default follows
138
+ // the endpoint: a named one is almost always MinIO or R2, which want
139
+ // the bucket in the path
140
+ this.pathStyle =
141
+ typeof options.pathStyle === 'boolean'
142
+ ? options.pathStyle
143
+ : Boolean(options.endpoint);
144
+
145
+ this.publicEndpoint = options.publicEndpoint
146
+ ? this.endpointOf({ endpoint: options.publicEndpoint })
147
+ : null;
148
+ }
149
+
150
+ /**
151
+ * The scheme, host and port an endpoint names
152
+ *
153
+ * @param {object} options `{ endpoint, region }`
154
+ * @returns {object} `{ protocol, host, port }`
155
+ * @throws when the endpoint cannot be read as a url
156
+ * @memberof S3Client
157
+ */
158
+ endpointOf(options) {
159
+ const named =
160
+ options.endpoint || `https://${AWS.replace('{region}', options.region)}`;
161
+ let url;
162
+
163
+ try {
164
+ url = new URL(/^[a-z]+:\/\//iu.test(named) ? named : `https://${named}`);
165
+ } catch (error) {
166
+ throw coded(
167
+ 'HENRI_UPLOAD_STORAGE_MISCONFIGURED',
168
+ `uploads.storage.endpoint is not a url: ${JSON.stringify(named)}`,
169
+ { cause: error }
170
+ );
171
+ }
172
+
173
+ return {
174
+ host: url.hostname,
175
+ port: url.port ? Number(url.port) : null,
176
+ protocol: url.protocol,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Everything that has to be true before a request is worth making
182
+ *
183
+ * @returns {S3Client} this
184
+ * @throws when the bucket or the credentials are missing or unusable
185
+ * @memberof S3Client
186
+ */
187
+ check() {
188
+ if (!BUCKET.test(this.bucket)) {
189
+ throw coded(
190
+ 'HENRI_UPLOAD_STORAGE_MISCONFIGURED',
191
+ this.bucket
192
+ ? `uploads.storage.bucket is not a bucket name: ${JSON.stringify(this.bucket)}`
193
+ : 'uploads.storage.bucket is not set'
194
+ );
195
+ }
196
+
197
+ if (!this.credentials.accessKeyId || !this.credentials.secretAccessKey) {
198
+ throw coded(
199
+ 'HENRI_UPLOAD_STORAGE_MISCONFIGURED',
200
+ 'the object store has no credentials: set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or accessKeyId and secretAccessKey in uploads.storage'
201
+ );
202
+ }
203
+
204
+ if (!/^[A-Za-z0-9][A-Za-z0-9-]{0,31}$/u.test(this.region)) {
205
+ throw coded(
206
+ 'HENRI_UPLOAD_STORAGE_MISCONFIGURED',
207
+ `uploads.storage.region is not a region: ${JSON.stringify(this.region)}`
208
+ );
209
+ }
210
+
211
+ return this;
212
+ }
213
+
214
+ /**
215
+ * Where one object sits: the host it is asked of, and the path
216
+ *
217
+ * @param {string} key the object key
218
+ * @param {object} [endpoint=null] another endpoint than the default one
219
+ * @returns {{host: string, origin: string, path: string}} the address
220
+ * @memberof S3Client
221
+ */
222
+ addressOf(key, endpoint = null) {
223
+ const at = endpoint || this;
224
+ const hostname = this.pathStyle ? at.host : `${this.bucket}.${at.host}`;
225
+ const authority = at.port ? `${hostname}:${at.port}` : hostname;
226
+
227
+ return {
228
+ // What the signature covers and what the `Host` header says: the
229
+ // authority, port and all
230
+ host: authority,
231
+ // Where the socket goes, which is the same name -- a virtual-host
232
+ // style bucket is a DNS name of its own, and connecting elsewhere
233
+ // would ask TLS for a certificate nobody issued
234
+ hostname,
235
+ origin: `${at.protocol}//${authority}`,
236
+ path: this.pathStyle ? `/${this.bucket}/${key}` : `/${key}`,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * One attempt at one request
242
+ *
243
+ * @param {object} options the request
244
+ * @param {string} options.method the method
245
+ * @param {string} options.key the object key
246
+ * @param {object} options.headers the headers beyond the signed ones
247
+ * @param {string} options.payload the sha256 of the body, hex
248
+ * @param {?string} options.file a file to stream as the body
249
+ * @param {?number} options.length the body's length
250
+ * @returns {Promise<http.IncomingMessage>} the response, unread
251
+ * @memberof S3Client
252
+ */
253
+ attempt({ file, headers, key, length, method, payload }) {
254
+ const { host, hostname, path } = this.addressOf(key);
255
+ const signed = sign({
256
+ credentials: this.credentials,
257
+ headers: Object.assign({}, headers, {
258
+ ...(length === null ? {} : { 'content-length': String(length) }),
259
+ }),
260
+ host,
261
+ method,
262
+ path,
263
+ payload,
264
+ query: {},
265
+ region: this.region,
266
+ });
267
+
268
+ return new Promise((resolve, reject) => {
269
+ const client = this.protocol === 'http:' ? http : https;
270
+ const request = client.request(
271
+ {
272
+ headers: signed,
273
+ host: hostname,
274
+ method,
275
+ path,
276
+ port: this.port || (this.protocol === 'http:' ? 80 : 443),
277
+ protocol: this.protocol,
278
+ // A signature covers the `Host` header, so the one that was signed
279
+ // is the one that goes out: node's own would leave the port off a
280
+ // default one and on everything else, and a signature is of bytes
281
+ setHost: false,
282
+ },
283
+ resolve
284
+ );
285
+
286
+ request.setTimeout(this.timeout, () => {
287
+ request.destroy(
288
+ coded(
289
+ 'HENRI_UPLOAD_STORAGE_FAILED',
290
+ `the object store said nothing for ${this.timeout}ms`
291
+ )
292
+ );
293
+ });
294
+ request.on('error', reject);
295
+
296
+ if (!file) {
297
+ return request.end();
298
+ }
299
+
300
+ const stream = fs.createReadStream(file);
301
+
302
+ stream.on('error', (error) => {
303
+ request.destroy(error);
304
+ reject(error);
305
+ });
306
+
307
+ return stream.pipe(request);
308
+ });
309
+ }
310
+
311
+ /**
312
+ * A request, made again when the reason it failed was a passing one.
313
+ *
314
+ * Only a network failure and the statuses a store answers when it is busy
315
+ * are tried again. A `403` is not: a signature that was refused is refused
316
+ * the second time too, and retrying it turns one clear failure into three
317
+ * slow ones.
318
+ *
319
+ * @param {object} options see `attempt()`
320
+ * @returns {Promise<http.IncomingMessage>} the response, unread
321
+ * @throws {StorageError} when every attempt failed
322
+ * @memberof S3Client
323
+ */
324
+ async send(options) {
325
+ let last = null;
326
+
327
+ for (let attempt = 0; attempt <= this.retries; attempt++) {
328
+ if (attempt > 0) {
329
+ const wait = BACKOFF * 2 ** (attempt - 1);
330
+
331
+ await new Promise((resolve) =>
332
+ setTimeout(resolve, wait + Math.random() * wait).unref()
333
+ );
334
+ }
335
+
336
+ try {
337
+ const response = await this.attempt(options);
338
+
339
+ if (!RETRYABLE.has(response.statusCode)) {
340
+ return response;
341
+ }
342
+
343
+ last = await this.failure(options, response);
344
+ } catch (error) {
345
+ last = coded(
346
+ 'HENRI_UPLOAD_STORAGE_FAILED',
347
+ `${options.method} ${options.key}: ${error.message}`,
348
+ { cause: error, key: options.key }
349
+ );
350
+ }
351
+
352
+ debug('%s %s failed: %s', options.method, options.key, last.message);
353
+ }
354
+
355
+ throw last;
356
+ }
357
+
358
+ /**
359
+ * The error a response that is not a success means
360
+ *
361
+ * @param {object} options the request that was made
362
+ * @param {http.IncomingMessage} response the response
363
+ * @returns {Promise<Error>} the error
364
+ * @memberof S3Client
365
+ */
366
+ async failure(options, response) {
367
+ const text = await body(response);
368
+ const { code, message } = reason(text);
369
+ const region = response.headers['x-amz-bucket-region'];
370
+ const status = response.statusCode;
371
+
372
+ if (status === 301 || status === 307) {
373
+ return coded(
374
+ 'HENRI_UPLOAD_STORAGE_MISCONFIGURED',
375
+ `the object store redirected ${options.method} ${options.key}${
376
+ region ? `: the bucket is in ${region}, not ${this.region}` : ''
377
+ }`,
378
+ { key: options.key, status }
379
+ );
380
+ }
381
+
382
+ return coded(
383
+ 'HENRI_UPLOAD_STORAGE_FAILED',
384
+ `${options.method} ${options.key}: the object store answered ${status}${
385
+ code ? ` ${code}` : ''
386
+ }${message ? ` (${message})` : ''}`,
387
+ { key: options.key, reason: code || null, status }
388
+ );
389
+ }
390
+
391
+ /**
392
+ * Writes an object from a local file
393
+ *
394
+ * @param {string} key the object key
395
+ * @param {object} options `{ file, length, checksum, type, name }`
396
+ * @returns {Promise<string>} the key
397
+ * @memberof S3Client
398
+ */
399
+ async put(key, { checksum, file, length, name, type }) {
400
+ const headers = { 'content-type': type || 'application/octet-stream' };
401
+
402
+ if (name) {
403
+ // The original name, kept where the object store keeps metadata rather
404
+ // than in the key, which is generated and stays generated. Encoded,
405
+ // because a header is latin-1 and a filename is not
406
+ headers['x-amz-meta-name'] = encodeURIComponent(name);
407
+ }
408
+
409
+ const response = await this.send({
410
+ file,
411
+ headers,
412
+ key,
413
+ length,
414
+ method: 'PUT',
415
+ payload: checksum,
416
+ });
417
+
418
+ if (response.statusCode !== 200) {
419
+ throw await this.failure({ key, method: 'PUT' }, response);
420
+ }
421
+
422
+ response.resume();
423
+
424
+ return key;
425
+ }
426
+
427
+ /**
428
+ * Reads an object
429
+ *
430
+ * @param {string} key the object key
431
+ * @returns {Promise<http.IncomingMessage>} the body, as a stream
432
+ * @throws {StorageError} when there is no such object
433
+ * @memberof S3Client
434
+ */
435
+ async get(key) {
436
+ const response = await this.send({
437
+ file: null,
438
+ headers: {},
439
+ key,
440
+ length: null,
441
+ method: 'GET',
442
+ payload: EMPTY,
443
+ });
444
+
445
+ if (response.statusCode !== 200) {
446
+ throw await this.failure({ key, method: 'GET' }, response);
447
+ }
448
+
449
+ return response;
450
+ }
451
+
452
+ /**
453
+ * What is known about an object
454
+ *
455
+ * @param {string} key the object key
456
+ * @returns {Promise<?object>} `{ size, modifiedAt }`, or null when there is none
457
+ * @memberof S3Client
458
+ */
459
+ async stat(key) {
460
+ const response = await this.send({
461
+ file: null,
462
+ headers: {},
463
+ key,
464
+ length: null,
465
+ method: 'HEAD',
466
+ payload: EMPTY,
467
+ });
468
+
469
+ response.resume();
470
+
471
+ if (response.statusCode === 404) {
472
+ return null;
473
+ }
474
+
475
+ if (response.statusCode !== 200) {
476
+ throw await this.failure({ key, method: 'HEAD' }, response);
477
+ }
478
+
479
+ return {
480
+ modifiedAt: new Date(response.headers['last-modified'] || Date.now()),
481
+ size: Number(response.headers['content-length'] || 0),
482
+ };
483
+ }
484
+
485
+ /**
486
+ * Removes an object
487
+ *
488
+ * @param {string} key the object key
489
+ * @returns {Promise<boolean>} true when it was there
490
+ * @memberof S3Client
491
+ */
492
+ async delete(key) {
493
+ // S3 answers 204 whether or not the object was there, so what is
494
+ // reported is what was found just before -- the contract asks for "true
495
+ // when something was removed", and a store with no answer to that
496
+ // question is asked the question that does have one
497
+ const found = await this.stat(key);
498
+ const response = await this.send({
499
+ file: null,
500
+ headers: {},
501
+ key,
502
+ length: null,
503
+ method: 'DELETE',
504
+ payload: EMPTY,
505
+ });
506
+
507
+ response.resume();
508
+
509
+ if (response.statusCode !== 204 && response.statusCode !== 200) {
510
+ throw await this.failure({ key, method: 'DELETE' }, response);
511
+ }
512
+
513
+ return Boolean(found);
514
+ }
515
+
516
+ /**
517
+ * A url that hands the object to a client without this process reading it
518
+ *
519
+ * @param {string} key the object key
520
+ * @param {object} options `{ expiresIn, query, now }`
521
+ * @returns {string} the url
522
+ * @memberof S3Client
523
+ */
524
+ url(key, { expiresIn, now = new Date(), query = {} } = {}) {
525
+ const { host, origin, path } = this.addressOf(key, this.publicEndpoint);
526
+
527
+ return presign({
528
+ credentials: this.credentials,
529
+ expiresIn,
530
+ host,
531
+ now,
532
+ origin,
533
+ path,
534
+ query,
535
+ region: this.region,
536
+ });
537
+ }
538
+ }
539
+
540
+ module.exports = {
541
+ AWS,
542
+ BACKOFF,
543
+ BUCKET,
544
+ ERROR_BODY,
545
+ RETRIES,
546
+ RETRYABLE,
547
+ S3Client,
548
+ TIMEOUT,
549
+ body,
550
+ reason,
551
+ sha256,
552
+ };
package/src/errors.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What `@usehenri/s3` throws.
3
+ *
4
+ * Every one of them carries a code from henri's own catalogue
5
+ * (`@usehenri/core/error-codes.json`), so an application branches on the
6
+ * reason instead of matching a message. A code is a string and nothing
7
+ * more: raising one imports nothing, which is what lets a package that only
8
+ * peer-depends on core raise one at all. `@usehenri/webhooks` does the same
9
+ * thing for the same reason.
10
+ */
11
+
12
+ /**
13
+ * A failure of the object store
14
+ *
15
+ * @class StorageError
16
+ * @extends {Error}
17
+ */
18
+ class StorageError extends Error {
19
+ /**
20
+ * Creates an instance of StorageError.
21
+ *
22
+ * @param {string} code A henri error code (ex: HENRI_UPLOAD_STORAGE_FAILED)
23
+ * @param {string} message What went wrong
24
+ * @param {object} [options={}] `cause` and anything to carry on the error
25
+ * @memberof StorageError
26
+ */
27
+ constructor(code, message, options = {}) {
28
+ const { cause, ...rest } = options;
29
+
30
+ super(message, cause ? { cause } : undefined);
31
+
32
+ this.name = 'StorageError';
33
+ this.code = code;
34
+ Object.assign(this, rest);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * A coded error, in one call
40
+ *
41
+ * @param {string} code A henri error code
42
+ * @param {string} message What went wrong
43
+ * @param {object} [rest={}] `cause` and anything to carry
44
+ * @returns {StorageError} the error
45
+ */
46
+ const coded = (code, message, rest = {}) =>
47
+ new StorageError(code, message, rest);
48
+
49
+ module.exports = { StorageError, coded };