@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/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +36 -0
- package/package.json +42 -10
- package/src/client.js +552 -0
- package/src/errors.js +49 -0
- package/src/signature.js +407 -0
- package/src/storage.js +404 -0
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 };
|