lean-s3 0.2.2 → 0.3.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/dist/index.d.ts +330 -24
- package/dist/index.js +1257 -5
- package/package.json +5 -7
- package/dist/AmzDate.d.ts +0 -7
- package/dist/AmzDate.js +0 -29
- package/dist/KeyCache.d.ts +0 -5
- package/dist/KeyCache.js +0 -21
- package/dist/S3BucketEntry.d.ts +0 -19
- package/dist/S3BucketEntry.js +0 -29
- package/dist/S3Client.d.ts +0 -210
- package/dist/S3Client.js +0 -632
- package/dist/S3Error.d.ts +0 -14
- package/dist/S3Error.js +0 -15
- package/dist/S3File.d.ts +0 -87
- package/dist/S3File.js +0 -192
- package/dist/S3Stat.d.ts +0 -8
- package/dist/S3Stat.js +0 -35
- package/dist/error.d.ts +0 -4
- package/dist/error.js +0 -57
- package/dist/index.test.d.ts +0 -1
- package/dist/sign.d.ts +0 -18
- package/dist/sign.js +0 -77
- package/dist/sign.test.d.ts +0 -1
- package/dist/test-common.d.ts +0 -1
- package/dist/test.integration.d.ts +0 -1
- package/dist/test.integration.js +0 -44
- package/dist/url.d.ts +0 -9
- package/dist/url.js +0 -52
- package/dist/url.test.d.ts +0 -1
package/dist/S3Client.js
DELETED
|
@@ -1,632 +0,0 @@
|
|
|
1
|
-
import { request, Agent } from "undici";
|
|
2
|
-
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
|
3
|
-
import S3File from "./S3File.js";
|
|
4
|
-
import S3Error from "./S3Error.js";
|
|
5
|
-
import S3BucketEntry from "./S3BucketEntry.js";
|
|
6
|
-
import KeyCache from "./KeyCache.js";
|
|
7
|
-
import * as amzDate from "./AmzDate.js";
|
|
8
|
-
import * as sign from "./sign.js";
|
|
9
|
-
import { buildRequestUrl, getRangeHeader, prepareHeadersForSigning, } from "./url.js";
|
|
10
|
-
import { getResponseError } from "./error.js";
|
|
11
|
-
export const write = Symbol("write");
|
|
12
|
-
export const stream = Symbol("stream");
|
|
13
|
-
const xmlParser = new XMLParser();
|
|
14
|
-
const xmlBuilder = new XMLBuilder({
|
|
15
|
-
attributeNamePrefix: "$",
|
|
16
|
-
ignoreAttributes: false,
|
|
17
|
-
});
|
|
18
|
-
/**
|
|
19
|
-
* A configured S3 bucket instance for managing files.
|
|
20
|
-
*
|
|
21
|
-
* @example
|
|
22
|
-
* ```js
|
|
23
|
-
* // Basic bucket setup
|
|
24
|
-
* const bucket = new S3Client({
|
|
25
|
-
* bucket: "my-bucket",
|
|
26
|
-
* accessKeyId: "key",
|
|
27
|
-
* secretAccessKey: "secret"
|
|
28
|
-
* });
|
|
29
|
-
* // Get file instance
|
|
30
|
-
* const file = bucket.file("image.jpg");
|
|
31
|
-
* await file.delete();
|
|
32
|
-
* ```
|
|
33
|
-
*/
|
|
34
|
-
export default class S3Client {
|
|
35
|
-
#options;
|
|
36
|
-
#keyCache = new KeyCache();
|
|
37
|
-
// TODO: pass options to this in client? Do we want to expose tjhe internal use of undici?
|
|
38
|
-
#dispatcher = new Agent();
|
|
39
|
-
/**
|
|
40
|
-
* 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.
|
|
41
|
-
*
|
|
42
|
-
* @param options The default options to use for the S3 client.
|
|
43
|
-
*/
|
|
44
|
-
constructor(options) {
|
|
45
|
-
if (!options) {
|
|
46
|
-
throw new Error("`options` is required.");
|
|
47
|
-
}
|
|
48
|
-
const { accessKeyId, secretAccessKey, endpoint, region, bucket, sessionToken, } = options;
|
|
49
|
-
if (!accessKeyId || typeof accessKeyId !== "string") {
|
|
50
|
-
throw new Error("`accessKeyId` is required.");
|
|
51
|
-
}
|
|
52
|
-
if (!secretAccessKey || typeof secretAccessKey !== "string") {
|
|
53
|
-
throw new Error("`secretAccessKey` is required.");
|
|
54
|
-
}
|
|
55
|
-
if (!endpoint || typeof endpoint !== "string") {
|
|
56
|
-
throw new Error("`endpoint` is required.");
|
|
57
|
-
}
|
|
58
|
-
if (!region || typeof region !== "string") {
|
|
59
|
-
throw new Error("`region` is required.");
|
|
60
|
-
}
|
|
61
|
-
if (!bucket || typeof bucket !== "string") {
|
|
62
|
-
throw new Error("`bucket` is required.");
|
|
63
|
-
}
|
|
64
|
-
this.#options = {
|
|
65
|
-
accessKeyId,
|
|
66
|
-
secretAccessKey,
|
|
67
|
-
endpoint,
|
|
68
|
-
region,
|
|
69
|
-
bucket,
|
|
70
|
-
sessionToken,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
/**
|
|
74
|
-
* Creates an S3File instance for the given path.
|
|
75
|
-
*
|
|
76
|
-
* @param {string} path The path to the object in the bucket. ALso known as [object key](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html).
|
|
77
|
-
* We recommend not using the following characters in a key name because of significant special character handling, which isn't consistent across all applications (see [AWS docs](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html)):
|
|
78
|
-
* - Backslash (`\\`)
|
|
79
|
-
* - Left brace (`{`)
|
|
80
|
-
* - Non-printable ASCII characters (128–255 decimal characters)
|
|
81
|
-
* - Caret or circumflex (`^`)
|
|
82
|
-
* - Right brace (`}`)
|
|
83
|
-
* - Percent character (`%`)
|
|
84
|
-
* - Grave accent or backtick (`\``)
|
|
85
|
-
* - Right bracket (`]`)
|
|
86
|
-
* - Quotation mark (`"`)
|
|
87
|
-
* - Greater than sign (`>`)
|
|
88
|
-
* - Left bracket (`[`)
|
|
89
|
-
* - Tilde (`~`)
|
|
90
|
-
* - Less than sign (`<`)
|
|
91
|
-
* - Pound sign (`#`)
|
|
92
|
-
* - Vertical bar or pipe (`|`)
|
|
93
|
-
*
|
|
94
|
-
* lean-s3 does not enforce these restrictions.
|
|
95
|
-
*
|
|
96
|
-
* @param {Partial<CreateFileInstanceOptions>} [options] TODO
|
|
97
|
-
* @example
|
|
98
|
-
* ```js
|
|
99
|
-
* const file = client.file("image.jpg");
|
|
100
|
-
* await file.write(imageData);
|
|
101
|
-
*
|
|
102
|
-
* const configFile = client.file("config.json", {
|
|
103
|
-
* type: "application/json",
|
|
104
|
-
* acl: "private"
|
|
105
|
-
* });
|
|
106
|
-
* ```
|
|
107
|
-
*/
|
|
108
|
-
file(path, options) {
|
|
109
|
-
// TODO: Check max path length in bytes
|
|
110
|
-
return new S3File(this, path, undefined, undefined, undefined);
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* Generate a presigned URL for temporary access to a file.
|
|
114
|
-
* Useful for generating upload/download URLs without exposing credentials.
|
|
115
|
-
* @returns The operation on {@link S3Client#presign.path} as a pre-signed URL.
|
|
116
|
-
*
|
|
117
|
-
* @example
|
|
118
|
-
* ```js
|
|
119
|
-
* const downloadUrl = client.presign("file.pdf", {
|
|
120
|
-
* expiresIn: 3600 // 1 hour
|
|
121
|
-
* });
|
|
122
|
-
* ```
|
|
123
|
-
*/
|
|
124
|
-
presign(path, { method = "GET", expiresIn = 3600, // TODO: Maybe rename this to expiresInSeconds
|
|
125
|
-
storageClass, acl, region: regionOverride, bucket: bucketOverride, endpoint: endpointOverride, } = {}) {
|
|
126
|
-
const now = new Date();
|
|
127
|
-
const date = amzDate.getAmzDate(now);
|
|
128
|
-
const options = this.#options;
|
|
129
|
-
const region = regionOverride ?? options.region;
|
|
130
|
-
const bucket = bucketOverride ?? options.bucket;
|
|
131
|
-
const endpoint = endpointOverride ?? options.endpoint;
|
|
132
|
-
const res = buildRequestUrl(endpoint, bucket, region, path);
|
|
133
|
-
const query = buildSearchParams(`${options.accessKeyId}/${date.date}/${region}/s3/aws4_request`, date, expiresIn, "host", undefined, storageClass, options.sessionToken, acl);
|
|
134
|
-
const dataDigest = sign.createCanonicalDataDigestHostOnly(method, res.pathname, query, res.host);
|
|
135
|
-
const signingKey = this.#keyCache.computeIfAbsent(date, region, options.accessKeyId, options.secretAccessKey);
|
|
136
|
-
const signature = sign.signCanonicalDataHash(signingKey, dataDigest, date, region);
|
|
137
|
-
// See `buildSearchParams` for casing on this parameter
|
|
138
|
-
res.search = `${query}&X-Amz-Signature=${signature}`;
|
|
139
|
-
return res.toString();
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Uses [`DeleteObjects`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) to delete multiple objects in a single request.
|
|
143
|
-
*/
|
|
144
|
-
async deleteObjects(objects, options = {}) {
|
|
145
|
-
const body = xmlBuilder.build({
|
|
146
|
-
Delete: {
|
|
147
|
-
Quiet: true,
|
|
148
|
-
Object: objects.map(o => ({
|
|
149
|
-
Key: typeof o === "string" ? o : o.key,
|
|
150
|
-
})),
|
|
151
|
-
},
|
|
152
|
-
});
|
|
153
|
-
const response = await this._signedRequest("POST", "", "delete=", // "=" is needed by minio for some reason
|
|
154
|
-
body, {
|
|
155
|
-
"content-md5": sign.md5Base64(body),
|
|
156
|
-
}, undefined, undefined, this.#options.bucket, options.signal);
|
|
157
|
-
if (response.statusCode === 200) {
|
|
158
|
-
const text = await response.body.text();
|
|
159
|
-
let res = undefined;
|
|
160
|
-
try {
|
|
161
|
-
// Quite mode omits all deleted elements, so it will be parsed as "", wich we need to coalasce to null/undefined
|
|
162
|
-
res = (xmlParser.parse(text)?.DeleteResult || undefined)?.Error ?? [];
|
|
163
|
-
}
|
|
164
|
-
catch (cause) {
|
|
165
|
-
// Possible according to AWS docs
|
|
166
|
-
throw new S3Error("Unknown", "", {
|
|
167
|
-
message: "S3 service responded with invalid XML.",
|
|
168
|
-
cause,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
if (!res || !Array.isArray(res)) {
|
|
172
|
-
throw new S3Error("Unknown", "", {
|
|
173
|
-
message: "Could not process response.",
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
const errors = res.map(e => ({
|
|
177
|
-
code: e.Code,
|
|
178
|
-
key: e.Key,
|
|
179
|
-
message: e.Message,
|
|
180
|
-
versionId: e.VersionId,
|
|
181
|
-
}));
|
|
182
|
-
return errors.length > 0 ? { errors } : null;
|
|
183
|
-
}
|
|
184
|
-
if (400 <= response.statusCode && response.statusCode < 500) {
|
|
185
|
-
throw await getResponseError(response, "");
|
|
186
|
-
}
|
|
187
|
-
response.body.dump(); // undici docs state that we should dump the body if not used
|
|
188
|
-
throw new Error(`Response code not implemented yet: ${response.statusCode}`);
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Creates a new bucket on the S3 server.
|
|
192
|
-
*
|
|
193
|
-
* @param name The name of the bucket to create. AWS the name according to [some rules](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html). The most important ones are:
|
|
194
|
-
* - Bucket names must be between `3` (min) and `63` (max) characters long.
|
|
195
|
-
* - Bucket names can consist only of lowercase letters, numbers, periods (`.`), and hyphens (`-`).
|
|
196
|
-
* - Bucket names must begin and end with a letter or number.
|
|
197
|
-
* - Bucket names must not contain two adjacent periods.
|
|
198
|
-
* - Bucket names must not be formatted as an IP address (for example, `192.168.5.4`).
|
|
199
|
-
*
|
|
200
|
-
* @throws {Error} If the bucket name is invalid.
|
|
201
|
-
* @throws {S3Error} If the bucket could not be created, e.g. if it already exists.
|
|
202
|
-
* @remarks Uses [`CreateBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html)
|
|
203
|
-
*/
|
|
204
|
-
async createBucket(name, options) {
|
|
205
|
-
ensureValidBucketName(name);
|
|
206
|
-
let body = undefined;
|
|
207
|
-
if (options) {
|
|
208
|
-
const location = options.location && (options.location.name || options.location.type)
|
|
209
|
-
? {
|
|
210
|
-
Name: options.location.name ?? undefined,
|
|
211
|
-
Type: options.location.type ?? undefined,
|
|
212
|
-
}
|
|
213
|
-
: undefined;
|
|
214
|
-
const bucket = options.info && (options.info.dataRedundancy || options.info.type)
|
|
215
|
-
? {
|
|
216
|
-
DataRedundancy: options.info.dataRedundancy ?? undefined,
|
|
217
|
-
Type: options.info.type ?? undefined,
|
|
218
|
-
}
|
|
219
|
-
: undefined;
|
|
220
|
-
body =
|
|
221
|
-
location || bucket || options.locationConstraint
|
|
222
|
-
? xmlBuilder.build({
|
|
223
|
-
CreateBucketConfiguration: {
|
|
224
|
-
$xmlns: "http://s3.amazonaws.com/doc/2006-03-01/",
|
|
225
|
-
LocationConstraint: options.locationConstraint ?? undefined,
|
|
226
|
-
Location: location,
|
|
227
|
-
Bucket: bucket,
|
|
228
|
-
},
|
|
229
|
-
})
|
|
230
|
-
: undefined;
|
|
231
|
-
}
|
|
232
|
-
const additionalSignedHeaders = body
|
|
233
|
-
? { "content-md5": sign.md5Base64(body) }
|
|
234
|
-
: undefined;
|
|
235
|
-
const response = await this._signedRequest("PUT", "", undefined, body, additionalSignedHeaders, undefined, undefined, name, options?.signal);
|
|
236
|
-
if (400 <= response.statusCode && response.statusCode < 500) {
|
|
237
|
-
throw await getResponseError(response, "");
|
|
238
|
-
}
|
|
239
|
-
await response.body.dump(); // undici docs state that we should dump the body if not used
|
|
240
|
-
if (response.statusCode === 200) {
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
throw new Error(`Response code not supported: ${response.statusCode}`);
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* Deletes a bucket from the S3 server.
|
|
247
|
-
* @param name The name of the bucket to delete. Same restrictions as in {@link S3Client#createBucket}.
|
|
248
|
-
* @throws {Error} If the bucket name is invalid.
|
|
249
|
-
* @throws {S3Error} If the bucket could not be deleted, e.g. if it is not empty.
|
|
250
|
-
* @remarks Uses [`DeleteBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html).
|
|
251
|
-
*/
|
|
252
|
-
async deleteBucket(name, options) {
|
|
253
|
-
ensureValidBucketName(name);
|
|
254
|
-
const response = await this._signedRequest("DELETE", "", undefined, undefined, undefined, undefined, undefined, name, options?.signal);
|
|
255
|
-
if (400 <= response.statusCode && response.statusCode < 500) {
|
|
256
|
-
throw await getResponseError(response, "");
|
|
257
|
-
}
|
|
258
|
-
await response.body.dump(); // undici docs state that we should dump the body if not used
|
|
259
|
-
if (response.statusCode === 204) {
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
throw new Error(`Response code not supported: ${response.statusCode}`);
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* Checks if a bucket exists.
|
|
266
|
-
* @param name The name of the bucket to delete. Same restrictions as in {@link S3Client#createBucket}.
|
|
267
|
-
* @throws {Error} If the bucket name is invalid.
|
|
268
|
-
* @remarks Uses [`HeadBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html).
|
|
269
|
-
*/
|
|
270
|
-
async bucketExists(name, options) {
|
|
271
|
-
ensureValidBucketName(name);
|
|
272
|
-
const response = await this._signedRequest("HEAD", "", undefined, undefined, undefined, undefined, undefined, name, options?.signal);
|
|
273
|
-
if (response.statusCode !== 404 &&
|
|
274
|
-
400 <= response.statusCode &&
|
|
275
|
-
response.statusCode < 500) {
|
|
276
|
-
throw await getResponseError(response, "");
|
|
277
|
-
}
|
|
278
|
-
await response.body.dump(); // undici docs state that we should dump the body if not used
|
|
279
|
-
if (response.statusCode === 200) {
|
|
280
|
-
return true;
|
|
281
|
-
}
|
|
282
|
-
if (response.statusCode === 404) {
|
|
283
|
-
return false;
|
|
284
|
-
}
|
|
285
|
-
throw new Error(`Response code not supported: ${response.statusCode}`);
|
|
286
|
-
}
|
|
287
|
-
//#region list
|
|
288
|
-
/**
|
|
289
|
-
* Uses [`ListObjectsV2`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) to iterate over all keys. Pagination and continuation is handled internally.
|
|
290
|
-
*/
|
|
291
|
-
async *listIterating(options) {
|
|
292
|
-
// only used to get smaller pages, so we can test this properly
|
|
293
|
-
const maxKeys = options?.internalPageSize ?? undefined;
|
|
294
|
-
let res = undefined;
|
|
295
|
-
let continuationToken = undefined;
|
|
296
|
-
do {
|
|
297
|
-
res = await this.list({
|
|
298
|
-
...options,
|
|
299
|
-
maxKeys,
|
|
300
|
-
continuationToken,
|
|
301
|
-
});
|
|
302
|
-
if (!res || res.contents.length === 0) {
|
|
303
|
-
break;
|
|
304
|
-
}
|
|
305
|
-
yield* res.contents;
|
|
306
|
-
continuationToken = res.nextContinuationToken;
|
|
307
|
-
} while (continuationToken);
|
|
308
|
-
}
|
|
309
|
-
/**
|
|
310
|
-
* Implements [`ListObjectsV2`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) to iterate over all keys.
|
|
311
|
-
*/
|
|
312
|
-
async list(options = {}) {
|
|
313
|
-
// See `benchmark-operations.js` on why we don't use URLSearchParams but string concat
|
|
314
|
-
// tldr: This is faster and we know the params exactly, so we can focus our encoding
|
|
315
|
-
// ! minio requires these params to be in alphabetical order
|
|
316
|
-
let query = "";
|
|
317
|
-
if (typeof options.continuationToken !== "undefined") {
|
|
318
|
-
if (typeof options.continuationToken !== "string") {
|
|
319
|
-
throw new TypeError("`continuationToken` should be a `string`.");
|
|
320
|
-
}
|
|
321
|
-
query += `continuation-token=${encodeURIComponent(options.continuationToken)}&`;
|
|
322
|
-
}
|
|
323
|
-
query += "list-type=2";
|
|
324
|
-
if (typeof options.maxKeys !== "undefined") {
|
|
325
|
-
if (typeof options.maxKeys !== "number") {
|
|
326
|
-
throw new TypeError("`maxKeys` should be a `number`.");
|
|
327
|
-
}
|
|
328
|
-
query += `&max-keys=${options.maxKeys}`; // no encoding needed, it's a number
|
|
329
|
-
}
|
|
330
|
-
// TODO: delimiter?
|
|
331
|
-
// plan `if(a)` check, so empty strings will also not go into this branch, omitting the parameter
|
|
332
|
-
if (options.prefix) {
|
|
333
|
-
if (typeof options.prefix !== "string") {
|
|
334
|
-
throw new TypeError("`prefix` should be a `string`.");
|
|
335
|
-
}
|
|
336
|
-
query += `&prefix=${encodeURIComponent(options.prefix)}`;
|
|
337
|
-
}
|
|
338
|
-
if (typeof options.startAfter !== "undefined") {
|
|
339
|
-
if (typeof options.startAfter !== "string") {
|
|
340
|
-
throw new TypeError("`startAfter` should be a `string`.");
|
|
341
|
-
}
|
|
342
|
-
query += `&start-after=${encodeURIComponent(options.startAfter)}`;
|
|
343
|
-
}
|
|
344
|
-
const response = await this._signedRequest("GET", "", query, undefined, undefined, undefined, undefined, options.bucket ?? this.#options.bucket, options.signal);
|
|
345
|
-
if (response.statusCode === 200) {
|
|
346
|
-
const text = await response.body.text();
|
|
347
|
-
let res = undefined;
|
|
348
|
-
try {
|
|
349
|
-
res = xmlParser.parse(text)?.ListBucketResult;
|
|
350
|
-
}
|
|
351
|
-
catch (cause) {
|
|
352
|
-
// Possible according to AWS docs
|
|
353
|
-
throw new S3Error("Unknown", "", {
|
|
354
|
-
message: "S3 service responded with invalid XML.",
|
|
355
|
-
cause,
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
if (!res) {
|
|
359
|
-
throw new S3Error("Unknown", "", {
|
|
360
|
-
message: "Could not read bucket contents.",
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
// S3 is weird and doesn't return an array if there is only one item
|
|
364
|
-
const contents = Array.isArray(res.Contents)
|
|
365
|
-
? (res.Contents?.map(S3BucketEntry.parse) ?? [])
|
|
366
|
-
: res.Contents
|
|
367
|
-
? [res.Contents]
|
|
368
|
-
: [];
|
|
369
|
-
return {
|
|
370
|
-
name: res.Name,
|
|
371
|
-
prefix: res.Prefix,
|
|
372
|
-
startAfter: res.StartAfter,
|
|
373
|
-
isTruncated: res.IsTruncated,
|
|
374
|
-
continuationToken: res.ContinuationToken,
|
|
375
|
-
maxKeys: res.MaxKeys,
|
|
376
|
-
keyCount: res.KeyCount,
|
|
377
|
-
nextContinuationToken: res.NextContinuationToken,
|
|
378
|
-
contents,
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
response.body.dump(); // undici docs state that we should dump the body if not used
|
|
382
|
-
throw new Error(`Response code not implemented yet: ${response.statusCode}`);
|
|
383
|
-
}
|
|
384
|
-
//#endregion
|
|
385
|
-
/**
|
|
386
|
-
* Do not use this. This is an internal method.
|
|
387
|
-
* TODO: Maybe move this into a separate free function?
|
|
388
|
-
* @internal
|
|
389
|
-
*/
|
|
390
|
-
async _signedRequest(method, pathWithoutBucket, query, body, additionalSignedHeaders, additionalUnsignedHeaders, contentHash, bucket, signal = undefined) {
|
|
391
|
-
const endpoint = this.#options.endpoint;
|
|
392
|
-
const region = this.#options.region;
|
|
393
|
-
const effectiveBucket = bucket ?? this.#options.bucket;
|
|
394
|
-
const url = buildRequestUrl(endpoint, effectiveBucket, region, pathWithoutBucket);
|
|
395
|
-
if (query) {
|
|
396
|
-
url.search = query;
|
|
397
|
-
}
|
|
398
|
-
const now = amzDate.now();
|
|
399
|
-
const contentHashStr = contentHash?.toString("hex") ?? sign.unsignedPayload;
|
|
400
|
-
// Signed headers have to be sorted
|
|
401
|
-
// To enhance sorting, we're adding all possible values somehow pre-ordered
|
|
402
|
-
const headersToBeSigned = prepareHeadersForSigning({
|
|
403
|
-
host: url.host,
|
|
404
|
-
"x-amz-date": now.dateTime,
|
|
405
|
-
"x-amz-content-sha256": contentHashStr,
|
|
406
|
-
...additionalSignedHeaders,
|
|
407
|
-
});
|
|
408
|
-
try {
|
|
409
|
-
return await request(url, {
|
|
410
|
-
method,
|
|
411
|
-
signal,
|
|
412
|
-
dispatcher: this.#dispatcher,
|
|
413
|
-
headers: {
|
|
414
|
-
...headersToBeSigned,
|
|
415
|
-
authorization: this.#getAuthorizationHeader(method, url.pathname, query ?? "", now, headersToBeSigned, region, contentHashStr, this.#options.accessKeyId, this.#options.secretAccessKey),
|
|
416
|
-
...additionalUnsignedHeaders,
|
|
417
|
-
"user-agent": "lean-s3",
|
|
418
|
-
},
|
|
419
|
-
body,
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
catch (cause) {
|
|
423
|
-
signal?.throwIfAborted();
|
|
424
|
-
throw new S3Error("Unknown", pathWithoutBucket, {
|
|
425
|
-
message: "Unknown error during S3 request.",
|
|
426
|
-
cause,
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* @internal
|
|
432
|
-
* @param {import("./index.d.ts").UndiciBodyInit} data TODO
|
|
433
|
-
*/
|
|
434
|
-
async [write](path, data, contentType, contentLength, contentHash, rageStart, rangeEndExclusive, signal = undefined) {
|
|
435
|
-
const bucket = this.#options.bucket;
|
|
436
|
-
const endpoint = this.#options.endpoint;
|
|
437
|
-
const region = this.#options.region;
|
|
438
|
-
const url = buildRequestUrl(endpoint, bucket, region, path);
|
|
439
|
-
const now = amzDate.now();
|
|
440
|
-
const contentHashStr = contentHash?.toString("hex") ?? sign.unsignedPayload;
|
|
441
|
-
// Signed headers have to be sorted
|
|
442
|
-
// To enhance sorting, we're adding all possible values somehow pre-ordered
|
|
443
|
-
const headersToBeSigned = prepareHeadersForSigning({
|
|
444
|
-
"content-length": contentLength?.toString() ?? undefined,
|
|
445
|
-
"content-type": contentType,
|
|
446
|
-
host: url.host,
|
|
447
|
-
range: getRangeHeader(rageStart, rangeEndExclusive),
|
|
448
|
-
"x-amz-content-sha256": contentHashStr,
|
|
449
|
-
"x-amz-date": now.dateTime,
|
|
450
|
-
});
|
|
451
|
-
/** @type {import("undici").Dispatcher.ResponseData<unknown> | undefined} */
|
|
452
|
-
let response = undefined;
|
|
453
|
-
try {
|
|
454
|
-
response = await request(url, {
|
|
455
|
-
method: "PUT",
|
|
456
|
-
signal,
|
|
457
|
-
dispatcher: this.#dispatcher,
|
|
458
|
-
headers: {
|
|
459
|
-
...headersToBeSigned,
|
|
460
|
-
authorization: this.#getAuthorizationHeader("PUT", url.pathname, url.search, now, headersToBeSigned, region, contentHashStr, this.#options.accessKeyId, this.#options.secretAccessKey),
|
|
461
|
-
"user-agent": "lean-s3",
|
|
462
|
-
},
|
|
463
|
-
body: data,
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
catch (cause) {
|
|
467
|
-
signal?.throwIfAborted();
|
|
468
|
-
throw new S3Error("Unknown", path, {
|
|
469
|
-
message: "Unknown error during S3 request.",
|
|
470
|
-
cause,
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
const status = response.statusCode;
|
|
474
|
-
if (200 <= status && status < 300) {
|
|
475
|
-
// everything seemed to work, no need to process response body
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
throw await getResponseError(response, path);
|
|
479
|
-
}
|
|
480
|
-
// TODO: Support abortSignal
|
|
481
|
-
/**
|
|
482
|
-
* @internal
|
|
483
|
-
*/
|
|
484
|
-
[stream](path, contentHash, rageStart, rangeEndExclusive) {
|
|
485
|
-
const bucket = this.#options.bucket;
|
|
486
|
-
const endpoint = this.#options.endpoint;
|
|
487
|
-
const region = this.#options.region;
|
|
488
|
-
const now = amzDate.now();
|
|
489
|
-
const url = buildRequestUrl(endpoint, bucket, region, path);
|
|
490
|
-
const range = getRangeHeader(rageStart, rangeEndExclusive);
|
|
491
|
-
const contentHashStr = contentHash?.toString("hex") ?? sign.unsignedPayload;
|
|
492
|
-
const headersToBeSigned = prepareHeadersForSigning({
|
|
493
|
-
"amz-sdk-invocation-id": crypto.randomUUID(),
|
|
494
|
-
// TODO: Maybe support retries and do "amz-sdk-request": attempt=1; max=3
|
|
495
|
-
host: url.host,
|
|
496
|
-
range,
|
|
497
|
-
// Hetzner doesnt care if the x-amz-content-sha256 header is missing, R2 requires it to be present
|
|
498
|
-
"x-amz-content-sha256": contentHashStr,
|
|
499
|
-
"x-amz-date": now.dateTime,
|
|
500
|
-
});
|
|
501
|
-
const ac = new AbortController();
|
|
502
|
-
return new ReadableStream({
|
|
503
|
-
type: "bytes",
|
|
504
|
-
start: controller => {
|
|
505
|
-
const onNetworkError = (cause) => {
|
|
506
|
-
controller.error(new S3Error("Unknown", path, {
|
|
507
|
-
message: undefined,
|
|
508
|
-
cause,
|
|
509
|
-
}));
|
|
510
|
-
};
|
|
511
|
-
request(url, {
|
|
512
|
-
method: "GET",
|
|
513
|
-
signal: ac.signal,
|
|
514
|
-
dispatcher: this.#dispatcher,
|
|
515
|
-
headers: {
|
|
516
|
-
...headersToBeSigned,
|
|
517
|
-
authorization: this.#getAuthorizationHeader("GET", url.pathname, url.search, now, headersToBeSigned, region, contentHashStr, this.#options.accessKeyId, this.#options.secretAccessKey),
|
|
518
|
-
"user-agent": "lean-s3",
|
|
519
|
-
},
|
|
520
|
-
}).then(response => {
|
|
521
|
-
const onData = controller.enqueue.bind(controller);
|
|
522
|
-
const onClose = controller.close.bind(controller);
|
|
523
|
-
const expectPartialResponse = range !== undefined;
|
|
524
|
-
const status = response.statusCode;
|
|
525
|
-
if (status === 200) {
|
|
526
|
-
if (expectPartialResponse) {
|
|
527
|
-
return controller.error(new S3Error("Unknown", path, {
|
|
528
|
-
message: "Expected partial response to range request.",
|
|
529
|
-
}));
|
|
530
|
-
}
|
|
531
|
-
response.body.on("data", onData);
|
|
532
|
-
response.body.once("error", onNetworkError);
|
|
533
|
-
response.body.once("end", onClose);
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
if (status === 206) {
|
|
537
|
-
if (!expectPartialResponse) {
|
|
538
|
-
return controller.error(new S3Error("Unknown", path, {
|
|
539
|
-
message: "Received partial response but expected a full response.",
|
|
540
|
-
}));
|
|
541
|
-
}
|
|
542
|
-
response.body.on("data", onData);
|
|
543
|
-
response.body.once("error", onNetworkError);
|
|
544
|
-
response.body.once("end", onClose);
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
if (400 <= status && status < 500) {
|
|
548
|
-
// Some providers actually support JSON via "accept: application/json", but we cant rely on it
|
|
549
|
-
const responseText = undefined;
|
|
550
|
-
const ct = response.headers["content-type"];
|
|
551
|
-
if (response.headers["content-type"] === "application/xml") {
|
|
552
|
-
return response.body.text().then(body => {
|
|
553
|
-
let error = undefined;
|
|
554
|
-
try {
|
|
555
|
-
error = xmlParser.parse(body);
|
|
556
|
-
}
|
|
557
|
-
catch (cause) {
|
|
558
|
-
return controller.error(new S3Error("Unknown", path, {
|
|
559
|
-
message: "Could not parse XML error response.",
|
|
560
|
-
cause,
|
|
561
|
-
}));
|
|
562
|
-
}
|
|
563
|
-
return controller.error(new S3Error(error.Code || "Unknown", path, {
|
|
564
|
-
message: error.Message || undefined, // Message might be "",
|
|
565
|
-
}));
|
|
566
|
-
}, onNetworkError);
|
|
567
|
-
}
|
|
568
|
-
return controller.error(new S3Error("Unknown", path, {
|
|
569
|
-
message: undefined,
|
|
570
|
-
cause: responseText,
|
|
571
|
-
}));
|
|
572
|
-
}
|
|
573
|
-
// TODO: Support other status codes
|
|
574
|
-
return controller.error(new Error(`Handling for status code ${status} not implemented yet. You might want to open an issue and describe your situation.`));
|
|
575
|
-
}, onNetworkError);
|
|
576
|
-
},
|
|
577
|
-
cancel(reason) {
|
|
578
|
-
ac.abort(reason);
|
|
579
|
-
},
|
|
580
|
-
});
|
|
581
|
-
}
|
|
582
|
-
#getAuthorizationHeader(method, path, query, date, sortedSignedHeaders, region, contentHashStr, accessKeyId, secretAccessKey) {
|
|
583
|
-
const dataDigest = sign.createCanonicalDataDigest(method, path, query, sortedSignedHeaders, contentHashStr);
|
|
584
|
-
const signingKey = this.#keyCache.computeIfAbsent(date, region, accessKeyId, secretAccessKey);
|
|
585
|
-
const signature = sign.signCanonicalDataHash(signingKey, dataDigest, date, region);
|
|
586
|
-
// no encodeURIComponent because because we assume that all headers don't need escaping
|
|
587
|
-
const signedHeadersSpec = Object.keys(sortedSignedHeaders).join(";");
|
|
588
|
-
const credentialSpec = `${accessKeyId}/${date.date}/${region}/s3/aws4_request`;
|
|
589
|
-
return `AWS4-HMAC-SHA256 Credential=${credentialSpec}, SignedHeaders=${signedHeadersSpec}, Signature=${signature}`;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
export function buildSearchParams(amzCredential, date, expiresIn, headerList, contentHashStr, storageClass, sessionToken, acl) {
|
|
593
|
-
// We tried to make these query params entirely lower-cased, just like the headers
|
|
594
|
-
// but Cloudflare R2 requires them to have this exact casing
|
|
595
|
-
// We didn't have any issues with them being in non-alphaetical order, but as some implementations decide to require sorting
|
|
596
|
-
// in non-pre-signed cases, we do it here as well
|
|
597
|
-
// See `benchmark-operations.js` on why we don't use URLSearchParams but string concat
|
|
598
|
-
let res = "";
|
|
599
|
-
if (acl) {
|
|
600
|
-
res += `X-Amz-Acl=${encodeURIComponent(acl)}&`;
|
|
601
|
-
}
|
|
602
|
-
res += "X-Amz-Algorithm=AWS4-HMAC-SHA256";
|
|
603
|
-
if (contentHashStr) {
|
|
604
|
-
// We assume that this is always hex-encoded, so no encoding needed
|
|
605
|
-
res += `&X-Amz-Content-Sha256=${contentHashStr}`;
|
|
606
|
-
}
|
|
607
|
-
res += `&X-Amz-Credential=${encodeURIComponent(amzCredential)}`;
|
|
608
|
-
res += `&X-Amz-Date=${date.dateTime}`; // internal dateTimes don't need encoding
|
|
609
|
-
res += `&X-Amz-Expires=${expiresIn}`; // number -> no encoding
|
|
610
|
-
if (sessionToken) {
|
|
611
|
-
res += `&X-Amz-Security-Token=${encodeURIComponent(sessionToken)}`;
|
|
612
|
-
}
|
|
613
|
-
res += `&X-Amz-SignedHeaders=${encodeURIComponent(headerList)}`;
|
|
614
|
-
if (storageClass) {
|
|
615
|
-
res += `&X-Amz-Storage-Class=${storageClass}`;
|
|
616
|
-
}
|
|
617
|
-
return res;
|
|
618
|
-
}
|
|
619
|
-
function ensureValidBucketName(name) {
|
|
620
|
-
if (name.length < 3 || name.length > 63) {
|
|
621
|
-
throw new Error("`name` must be between 3 and 63 characters long.");
|
|
622
|
-
}
|
|
623
|
-
if (name.startsWith(".") || name.endsWith(".")) {
|
|
624
|
-
throw new Error("`name` must not start or end with a period (.)");
|
|
625
|
-
}
|
|
626
|
-
if (!/^[a-z0-9.-]+$/.test(name)) {
|
|
627
|
-
throw new Error("`name` can only contain lowercase letters, numbers, periods (.), and hyphens (-).");
|
|
628
|
-
}
|
|
629
|
-
if (name.includes("..")) {
|
|
630
|
-
throw new Error("`name` must not contain two adjacent periods (..)");
|
|
631
|
-
}
|
|
632
|
-
}
|
package/dist/S3Error.d.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export default class S3Error extends Error {
|
|
2
|
-
readonly code: string;
|
|
3
|
-
readonly path: string;
|
|
4
|
-
readonly message: string;
|
|
5
|
-
readonly requestId: string | undefined;
|
|
6
|
-
readonly hostId: string | undefined;
|
|
7
|
-
constructor(code: string, path: string, { message, requestId, hostId, cause }?: S3ErrorOptions);
|
|
8
|
-
}
|
|
9
|
-
export type S3ErrorOptions = {
|
|
10
|
-
message?: string | undefined;
|
|
11
|
-
requestId?: string | undefined;
|
|
12
|
-
hostId?: string | undefined;
|
|
13
|
-
cause?: unknown;
|
|
14
|
-
};
|
package/dist/S3Error.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export default class S3Error extends Error {
|
|
2
|
-
code;
|
|
3
|
-
path;
|
|
4
|
-
message;
|
|
5
|
-
requestId;
|
|
6
|
-
hostId;
|
|
7
|
-
constructor(code, path, { message = undefined, requestId = undefined, hostId = undefined, cause = undefined, } = {}) {
|
|
8
|
-
super(message, { cause });
|
|
9
|
-
this.code = code;
|
|
10
|
-
this.path = path;
|
|
11
|
-
this.message = message ?? "Some unknown error occurred.";
|
|
12
|
-
this.requestId = requestId;
|
|
13
|
-
this.hostId = hostId;
|
|
14
|
-
}
|
|
15
|
-
}
|