lean-s3 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/S3BucketEntry.d.ts +1 -0
- package/dist/S3Client.d.ts +39 -4
- package/dist/S3Client.js +66 -44
- package/dist/S3Error.d.ts +1 -1
- package/dist/S3File.d.ts +19 -4
- package/dist/S3File.js +30 -33
- package/dist/S3Stat.d.ts +1 -2
- package/dist/S3Stat.js +7 -7
- package/dist/error.d.ts +4 -0
- package/dist/error.js +57 -0
- package/dist/index.d.ts +9 -1
- package/dist/sign.d.ts +2 -0
- package/dist/test-common.d.ts +1 -4
- package/dist/test.integration.js +27 -2
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# lean-s3 [](https://www.npmjs.com/package/lean-s3)
|
|
2
2
|
|
|
3
|
-
A server-side S3 API for the regular user. lean-s3 tries to provide the 80% of S3 that most people use. It is heavily inspired by [Bun's S3 API](https://bun.sh/docs/api/s3). Requires a Node.js version
|
|
3
|
+
A server-side S3 API for the regular user. lean-s3 tries to provide the 80% of S3 that most people use. It is heavily inspired by [Bun's S3 API](https://bun.sh/docs/api/s3). Requires a supported Node.js version.
|
|
4
4
|
|
|
5
5
|
## Elevator Pitch
|
|
6
6
|
```js
|
|
@@ -125,6 +125,8 @@ See [DESIGN_DECISIONS.md](./DESIGN_DECISIONS.md) to read about why this library
|
|
|
125
125
|
|
|
126
126
|
### Bucket Operations
|
|
127
127
|
- ✅ [`CreateBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html) via `.createBucket`
|
|
128
|
+
- ✅ [`DeleteBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html) via `.deleteBucket`
|
|
129
|
+
- ✅ [`HeadBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html) via `.bucketExists`
|
|
128
130
|
|
|
129
131
|
### Object Operations
|
|
130
132
|
- ✅ [`ListObjectsV2`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) via `.list`/`.listIterating`
|
package/dist/S3BucketEntry.d.ts
CHANGED
package/dist/S3Client.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { type Dispatcher } from "undici";
|
|
1
2
|
import S3File from "./S3File.ts";
|
|
2
3
|
import S3BucketEntry from "./S3BucketEntry.ts";
|
|
3
4
|
import * as amzDate from "./AmzDate.ts";
|
|
4
|
-
import type { Acl, BucketInfo, BucketLocationInfo, PresignableHttpMethod, StorageClass, UndiciBodyInit } from "./index.ts";
|
|
5
|
+
import type { Acl, BucketInfo, BucketLocationInfo, HttpMethod, PresignableHttpMethod, StorageClass, UndiciBodyInit } from "./index.ts";
|
|
5
6
|
export declare const write: unique symbol;
|
|
6
7
|
export declare const stream: unique symbol;
|
|
7
8
|
export interface S3ClientOptions {
|
|
@@ -13,19 +14,21 @@ export interface S3ClientOptions {
|
|
|
13
14
|
sessionToken?: string;
|
|
14
15
|
}
|
|
15
16
|
export type OverridableS3ClientOptions = Pick<S3ClientOptions, "region" | "bucket" | "endpoint">;
|
|
16
|
-
|
|
17
|
+
// biome-ignore lint/complexity/noBannedTypes: TODO
|
|
18
|
+
export type CreateFileInstanceOptions = {}; // TODO
|
|
17
19
|
export type DeleteObjectsOptions = {
|
|
18
20
|
signal?: AbortSignal;
|
|
19
21
|
};
|
|
20
22
|
export interface S3FilePresignOptions {
|
|
21
23
|
contentHash: Buffer;
|
|
22
24
|
/** Seconds. */
|
|
23
|
-
expiresIn: number;
|
|
25
|
+
expiresIn: number; // TODO: Maybe support Temporal.Duration once major support arrives
|
|
24
26
|
method: PresignableHttpMethod;
|
|
25
27
|
storageClass: StorageClass;
|
|
26
28
|
acl: Acl;
|
|
27
29
|
}
|
|
28
30
|
export type ListObjectsOptions = {
|
|
31
|
+
bucket?: string;
|
|
29
32
|
prefix?: string;
|
|
30
33
|
maxKeys?: number;
|
|
31
34
|
startAfter?: string;
|
|
@@ -33,6 +36,7 @@ export type ListObjectsOptions = {
|
|
|
33
36
|
signal?: AbortSignal;
|
|
34
37
|
};
|
|
35
38
|
export type ListObjectsIteratingOptions = {
|
|
39
|
+
bucket?: string;
|
|
36
40
|
prefix?: string;
|
|
37
41
|
startAfter?: string;
|
|
38
42
|
signal?: AbortSignal;
|
|
@@ -55,6 +59,12 @@ export type BucketCreationOptions = {
|
|
|
55
59
|
info?: BucketInfo;
|
|
56
60
|
signal?: AbortSignal;
|
|
57
61
|
};
|
|
62
|
+
export type BucketDeletionOptions = {
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
};
|
|
65
|
+
export type BucketExistsOptions = {
|
|
66
|
+
signal?: AbortSignal;
|
|
67
|
+
};
|
|
58
68
|
/**
|
|
59
69
|
* A configured S3 bucket instance for managing files.
|
|
60
70
|
*
|
|
@@ -128,7 +138,7 @@ export default class S3Client {
|
|
|
128
138
|
* ```
|
|
129
139
|
*/
|
|
130
140
|
presign(path: string, { method, expiresIn, // TODO: Maybe rename this to expiresInSeconds
|
|
131
|
-
storageClass, acl, region: regionOverride, bucket: bucketOverride, endpoint: endpointOverride
|
|
141
|
+
storageClass, acl, region: regionOverride, bucket: bucketOverride, endpoint: endpointOverride }?: Partial<S3FilePresignOptions & OverridableS3ClientOptions>): string;
|
|
132
142
|
/**
|
|
133
143
|
* Uses [`DeleteObjects`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html) to delete multiple objects in a single request.
|
|
134
144
|
*/
|
|
@@ -151,9 +161,26 @@ export default class S3Client {
|
|
|
151
161
|
* - Bucket names must not be formatted as an IP address (for example, `192.168.5.4`).
|
|
152
162
|
*
|
|
153
163
|
* @throws {Error} If the bucket name is invalid.
|
|
164
|
+
* @throws {S3Error} If the bucket could not be created, e.g. if it already exists.
|
|
154
165
|
* @remarks Uses [`CreateBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html)
|
|
155
166
|
*/
|
|
156
167
|
createBucket(name: string, options?: BucketCreationOptions): Promise<void>;
|
|
168
|
+
/**
|
|
169
|
+
* Deletes a bucket from the S3 server.
|
|
170
|
+
* @param name The name of the bucket to delete. Same restrictions as in {@link S3Client#createBucket}.
|
|
171
|
+
* @throws {Error} If the bucket name is invalid.
|
|
172
|
+
* @throws {S3Error} If the bucket could not be deleted, e.g. if it is not empty.
|
|
173
|
+
* @remarks Uses [`DeleteBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucket.html).
|
|
174
|
+
*/
|
|
175
|
+
deleteBucket(name: string, options?: BucketDeletionOptions): Promise<void>;
|
|
176
|
+
/**
|
|
177
|
+
* Checks if a bucket exists.
|
|
178
|
+
* @param name The name of the bucket to delete. Same restrictions as in {@link S3Client#createBucket}.
|
|
179
|
+
* @throws {Error} If the bucket name is invalid.
|
|
180
|
+
* @remarks Uses [`HeadBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadBucket.html).
|
|
181
|
+
*/
|
|
182
|
+
bucketExists(name: string, options?: BucketExistsOptions): Promise<boolean>;
|
|
183
|
+
//#region list
|
|
157
184
|
/**
|
|
158
185
|
* Uses [`ListObjectsV2`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) to iterate over all keys. Pagination and continuation is handled internally.
|
|
159
186
|
*/
|
|
@@ -162,11 +189,19 @@ export default class S3Client {
|
|
|
162
189
|
* Implements [`ListObjectsV2`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html) to iterate over all keys.
|
|
163
190
|
*/
|
|
164
191
|
list(options?: ListObjectsOptions): Promise<ListObjectsResponse>;
|
|
192
|
+
//#endregion
|
|
193
|
+
/**
|
|
194
|
+
* Do not use this. This is an internal method.
|
|
195
|
+
* TODO: Maybe move this into a separate free function?
|
|
196
|
+
* @internal
|
|
197
|
+
*/
|
|
198
|
+
_signedRequest(method: HttpMethod, pathWithoutBucket: string, query: string | undefined, body: UndiciBodyInit | undefined, additionalSignedHeaders: Record<string, string> | undefined, additionalUnsignedHeaders: Record<string, string> | undefined, contentHash: Buffer | undefined, bucket: string | undefined, signal?: AbortSignal | undefined): Promise<Dispatcher.ResponseData<null>>;
|
|
165
199
|
/**
|
|
166
200
|
* @internal
|
|
167
201
|
* @param {import("./index.d.ts").UndiciBodyInit} data TODO
|
|
168
202
|
*/
|
|
169
203
|
[write](path: string, data: UndiciBodyInit, contentType: string, contentLength: number | undefined, contentHash: Buffer | undefined, rageStart: number | undefined, rangeEndExclusive: number | undefined, signal?: AbortSignal | undefined): Promise<void>;
|
|
204
|
+
// TODO: Support abortSignal
|
|
170
205
|
/**
|
|
171
206
|
* @internal
|
|
172
207
|
*/
|
package/dist/S3Client.js
CHANGED
|
@@ -7,6 +7,7 @@ import KeyCache from "./KeyCache.js";
|
|
|
7
7
|
import * as amzDate from "./AmzDate.js";
|
|
8
8
|
import * as sign from "./sign.js";
|
|
9
9
|
import { buildRequestUrl, getRangeHeader, prepareHeadersForSigning, } from "./url.js";
|
|
10
|
+
import { getResponseError } from "./error.js";
|
|
10
11
|
export const write = Symbol("write");
|
|
11
12
|
export const stream = Symbol("stream");
|
|
12
13
|
const xmlParser = new XMLParser();
|
|
@@ -149,7 +150,7 @@ export default class S3Client {
|
|
|
149
150
|
})),
|
|
150
151
|
},
|
|
151
152
|
});
|
|
152
|
-
const response = await this
|
|
153
|
+
const response = await this._signedRequest("POST", "", "delete=", // "=" is needed by minio for some reason
|
|
153
154
|
body, {
|
|
154
155
|
"content-md5": sign.md5Base64(body),
|
|
155
156
|
}, undefined, undefined, this.#options.bucket, options.signal);
|
|
@@ -197,12 +198,11 @@ export default class S3Client {
|
|
|
197
198
|
* - Bucket names must not be formatted as an IP address (for example, `192.168.5.4`).
|
|
198
199
|
*
|
|
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.
|
|
200
202
|
* @remarks Uses [`CreateBucket`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateBucket.html)
|
|
201
203
|
*/
|
|
202
204
|
async createBucket(name, options) {
|
|
203
|
-
|
|
204
|
-
throw new Error("`name` must be between 3 and 63 characters long.");
|
|
205
|
-
}
|
|
205
|
+
ensureValidBucketName(name);
|
|
206
206
|
let body = undefined;
|
|
207
207
|
if (options) {
|
|
208
208
|
const location = options.location && (options.location.name || options.location.type)
|
|
@@ -232,15 +232,56 @@ export default class S3Client {
|
|
|
232
232
|
const additionalSignedHeaders = body
|
|
233
233
|
? { "content-md5": sign.md5Base64(body) }
|
|
234
234
|
: undefined;
|
|
235
|
-
const response = await this
|
|
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
|
|
236
240
|
if (response.statusCode === 200) {
|
|
237
|
-
response.body.dump(); // undici docs state that we should dump the body if not used
|
|
238
241
|
return;
|
|
239
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);
|
|
240
255
|
if (400 <= response.statusCode && response.statusCode < 500) {
|
|
241
256
|
throw await getResponseError(response, "");
|
|
242
257
|
}
|
|
243
|
-
response.body.dump(); // undici docs state that we should dump the body if not used
|
|
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
|
+
}
|
|
244
285
|
throw new Error(`Response code not supported: ${response.statusCode}`);
|
|
245
286
|
}
|
|
246
287
|
//#region list
|
|
@@ -300,7 +341,7 @@ export default class S3Client {
|
|
|
300
341
|
}
|
|
301
342
|
query += `&start-after=${encodeURIComponent(options.startAfter)}`;
|
|
302
343
|
}
|
|
303
|
-
const response = await this
|
|
344
|
+
const response = await this._signedRequest("GET", "", query, undefined, undefined, undefined, undefined, options.bucket ?? this.#options.bucket, options.signal);
|
|
304
345
|
if (response.statusCode === 200) {
|
|
305
346
|
const text = await response.body.text();
|
|
306
347
|
let res = undefined;
|
|
@@ -341,10 +382,16 @@ export default class S3Client {
|
|
|
341
382
|
throw new Error(`Response code not implemented yet: ${response.statusCode}`);
|
|
342
383
|
}
|
|
343
384
|
//#endregion
|
|
344
|
-
|
|
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) {
|
|
345
391
|
const endpoint = this.#options.endpoint;
|
|
346
392
|
const region = this.#options.region;
|
|
347
|
-
const
|
|
393
|
+
const effectiveBucket = bucket ?? this.#options.bucket;
|
|
394
|
+
const url = buildRequestUrl(endpoint, effectiveBucket, region, pathWithoutBucket);
|
|
348
395
|
if (query) {
|
|
349
396
|
url.search = query;
|
|
350
397
|
}
|
|
@@ -569,42 +616,17 @@ export function buildSearchParams(amzCredential, date, expiresIn, headerList, co
|
|
|
569
616
|
}
|
|
570
617
|
return res;
|
|
571
618
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
body = await response.body.text();
|
|
576
|
-
}
|
|
577
|
-
catch (cause) {
|
|
578
|
-
return new S3Error("Unknown", path, {
|
|
579
|
-
message: "Could not read response body.",
|
|
580
|
-
cause,
|
|
581
|
-
});
|
|
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.");
|
|
582
622
|
}
|
|
583
|
-
if (
|
|
584
|
-
|
|
623
|
+
if (name.startsWith(".") || name.endsWith(".")) {
|
|
624
|
+
throw new Error("`name` must not start or end with a period (.)");
|
|
585
625
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
function parseAndGetXmlError(body, path) {
|
|
591
|
-
let error = undefined;
|
|
592
|
-
try {
|
|
593
|
-
error = xmlParser.parse(body);
|
|
626
|
+
if (!/^[a-z0-9.-]+$/.test(name)) {
|
|
627
|
+
throw new Error("`name` can only contain lowercase letters, numbers, periods (.), and hyphens (-).");
|
|
594
628
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
message: "Could not parse XML error response.",
|
|
598
|
-
cause,
|
|
599
|
-
});
|
|
600
|
-
}
|
|
601
|
-
if (error.Error) {
|
|
602
|
-
const e = error.Error;
|
|
603
|
-
return new S3Error(e.Code || "Unknown", path, {
|
|
604
|
-
message: e.Message || undefined, // Message might be "",
|
|
605
|
-
});
|
|
629
|
+
if (name.includes("..")) {
|
|
630
|
+
throw new Error("`name` must not contain two adjacent periods (..)");
|
|
606
631
|
}
|
|
607
|
-
return new S3Error(error.Code || "Unknown", path, {
|
|
608
|
-
message: error.Message || undefined, // Message might be "",
|
|
609
|
-
});
|
|
610
632
|
}
|
package/dist/S3Error.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export default class S3Error extends Error {
|
|
|
4
4
|
readonly message: string;
|
|
5
5
|
readonly requestId: string | undefined;
|
|
6
6
|
readonly hostId: string | undefined;
|
|
7
|
-
constructor(code: string, path: string, { message, requestId, hostId, cause
|
|
7
|
+
constructor(code: string, path: string, { message, requestId, hostId, cause }?: S3ErrorOptions);
|
|
8
8
|
}
|
|
9
9
|
export type S3ErrorOptions = {
|
|
10
10
|
message?: string | undefined;
|
package/dist/S3File.d.ts
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
|
-
import S3Stat from "./S3Stat.ts";
|
|
2
1
|
import type S3Client from "./S3Client.ts";
|
|
3
|
-
import { type OverridableS3ClientOptions } from "./S3Client.ts";
|
|
4
2
|
import type { ByteSource } from "./index.ts";
|
|
3
|
+
import S3Stat from "./S3Stat.ts";
|
|
4
|
+
import { type OverridableS3ClientOptions } from "./S3Client.ts";
|
|
5
|
+
// TODO: If we want to hack around, we can use this to access the private implementation of the "get stream" algorithm used by Node.js's blob internally
|
|
6
|
+
// We probably have to do this some day if the fetch implementation is moved to internals.
|
|
7
|
+
// If this happens, fetch will probably use `[kHandle].getReader()` instead of .stream() to read the Blob
|
|
8
|
+
// This would break our use-case of passing an S3File as a body
|
|
9
|
+
// Using this hack would also make `.text()`, `.bytes()` etc. "just work" in every case, since these use `[kHandle]` internally as well.
|
|
10
|
+
// We now resort back into overriding text/bytes/etc. But as soon as another internal Node.js API uses this functionality, this would probably also use `[kHandle]` and bypass our data.
|
|
11
|
+
// const kHandle = Object.getOwnPropertySymbols(new Blob).find(s => s.toString() === 'Symbol(kHandle)');
|
|
5
12
|
export default class S3File {
|
|
6
13
|
#private;
|
|
7
14
|
/**
|
|
8
15
|
* @internal
|
|
9
16
|
*/
|
|
10
17
|
constructor(client: S3Client, path: string, start: number | undefined, end: number | undefined, contentType: string | undefined);
|
|
18
|
+
// TODO: slice overloads
|
|
11
19
|
slice(start?: number | undefined, end?: number | undefined, contentType?: string | undefined): S3File;
|
|
12
20
|
/**
|
|
13
21
|
* Get the stat of a file in the bucket. Uses `HEAD` request to check existence.
|
|
14
22
|
*
|
|
15
23
|
* @remarks Uses [`HeadObject`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html).
|
|
16
|
-
* @throws {
|
|
24
|
+
* @throws {S3Error} If the file does not exist or the server has some other issues.
|
|
25
|
+
* @throws {Error} If the server returns an invalid response.
|
|
17
26
|
*/
|
|
18
27
|
stat({ signal }?: Partial<S3StatOptions>): Promise<S3Stat>;
|
|
19
28
|
/**
|
|
@@ -21,11 +30,12 @@ export default class S3File {
|
|
|
21
30
|
*
|
|
22
31
|
* @remarks Uses [`HeadObject`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html).
|
|
23
32
|
*/
|
|
24
|
-
exists({ signal
|
|
33
|
+
exists({ signal }?: Partial<S3FileExistsOptions>): Promise<boolean>;
|
|
25
34
|
/**
|
|
26
35
|
* Delete a file from the bucket.
|
|
27
36
|
*
|
|
28
37
|
* @remarks Uses [`DeleteObject`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html).
|
|
38
|
+
* @remarks `versionId` not supported.
|
|
29
39
|
*
|
|
30
40
|
* @param {Partial<S3FileDeleteOptions>} [options]
|
|
31
41
|
*
|
|
@@ -47,6 +57,11 @@ export default class S3File {
|
|
|
47
57
|
toString(): string;
|
|
48
58
|
/** @returns {Promise<unknown>} */
|
|
49
59
|
json(): Promise<unknown>;
|
|
60
|
+
// TODO
|
|
61
|
+
// /** @returns {Promise<Uint8Array>} */
|
|
62
|
+
// bytes() {
|
|
63
|
+
// return new Response(this.stream()).bytes(); // TODO: Does this exist?
|
|
64
|
+
// }
|
|
50
65
|
/** @returns {Promise<ArrayBuffer>} */
|
|
51
66
|
arrayBuffer(): Promise<ArrayBuffer>;
|
|
52
67
|
/** @returns {Promise<string>} */
|
package/dist/S3File.js
CHANGED
|
@@ -3,6 +3,7 @@ import S3Error from "./S3Error.js";
|
|
|
3
3
|
import S3Stat from "./S3Stat.js";
|
|
4
4
|
import { write, stream } from "./S3Client.js";
|
|
5
5
|
import { sha256 } from "./sign.js";
|
|
6
|
+
import { fromStatusCode, getResponseError } from "./error.js";
|
|
6
7
|
// TODO: If we want to hack around, we can use this to access the private implementation of the "get stream" algorithm used by Node.js's blob internally
|
|
7
8
|
// We probably have to do this some day if the fetch implementation is moved to internals.
|
|
8
9
|
// If this happens, fetch will probably use `[kHandle].getReader()` instead of .stream() to read the Blob
|
|
@@ -41,28 +42,23 @@ export default class S3File {
|
|
|
41
42
|
* Get the stat of a file in the bucket. Uses `HEAD` request to check existence.
|
|
42
43
|
*
|
|
43
44
|
* @remarks Uses [`HeadObject`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html).
|
|
44
|
-
* @throws {
|
|
45
|
+
* @throws {S3Error} If the file does not exist or the server has some other issues.
|
|
46
|
+
* @throws {Error} If the server returns an invalid response.
|
|
45
47
|
*/
|
|
46
48
|
async stat({ signal } = {}) {
|
|
47
49
|
// TODO: Support all options
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
throw new S3Error("NoSuchKey", this.#path);
|
|
56
|
-
default:
|
|
57
|
-
// TODO: Process response body
|
|
58
|
-
throw new S3Error("Unknown", this.#path);
|
|
50
|
+
const response = await this.#client._signedRequest("HEAD", this.#path, undefined, undefined, undefined, undefined, undefined, undefined, signal);
|
|
51
|
+
// Heads don't have a body, but we still need to consume it to avoid leaks
|
|
52
|
+
await response.body.dump();
|
|
53
|
+
if (200 <= response.statusCode && response.statusCode < 300) {
|
|
54
|
+
const result = S3Stat.tryParseFromHeaders(response.headers);
|
|
55
|
+
if (!result) {
|
|
56
|
+
throw new Error("S3 server returned an invalid response for `HeadObject`");
|
|
59
57
|
}
|
|
58
|
+
return result;
|
|
60
59
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
throw new Error("S3 server returned an invalid response for HEAD");
|
|
64
|
-
}
|
|
65
|
-
return result;
|
|
60
|
+
throw (fromStatusCode(response.statusCode, this.#path) ??
|
|
61
|
+
new Error(`S3 server returned an unsupported status code for \`HeadObject\`: ${response.statusCode}`));
|
|
66
62
|
}
|
|
67
63
|
/**
|
|
68
64
|
* Check if a file exists in the bucket. Uses `HEAD` request to check existence.
|
|
@@ -71,15 +67,23 @@ export default class S3File {
|
|
|
71
67
|
*/
|
|
72
68
|
async exists({ signal, } = {}) {
|
|
73
69
|
// TODO: Support all options
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
const response = await this.#client._signedRequest("HEAD", this.#path, undefined, undefined, undefined, undefined, undefined, undefined, signal);
|
|
71
|
+
// Heads don't have a body, but we still need to consume it to avoid leaks
|
|
72
|
+
await response.body.dump();
|
|
73
|
+
if (200 <= response.statusCode && response.statusCode < 300) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (response.statusCode === 404) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
throw (fromStatusCode(response.statusCode, this.#path) ??
|
|
80
|
+
new Error(`S3 server returned an unsupported status code for \`HeadObject\`: ${response.statusCode}`));
|
|
78
81
|
}
|
|
79
82
|
/**
|
|
80
83
|
* Delete a file from the bucket.
|
|
81
84
|
*
|
|
82
85
|
* @remarks Uses [`DeleteObject`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObject.html).
|
|
86
|
+
* @remarks `versionId` not supported.
|
|
83
87
|
*
|
|
84
88
|
* @param {Partial<S3FileDeleteOptions>} [options]
|
|
85
89
|
*
|
|
@@ -99,19 +103,12 @@ export default class S3File {
|
|
|
99
103
|
*/
|
|
100
104
|
async delete({ signal } = {}) {
|
|
101
105
|
// TODO: Support all options
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
switch (response.status) {
|
|
107
|
-
case 404:
|
|
108
|
-
// TODO: Process response body
|
|
109
|
-
throw new S3Error("NoSuchKey", this.#path);
|
|
110
|
-
default:
|
|
111
|
-
// TODO: Process response body
|
|
112
|
-
throw new S3Error("Unknown", this.#path);
|
|
113
|
-
}
|
|
106
|
+
const response = await this.#client._signedRequest("DELETE", this.#path, undefined, undefined, undefined, undefined, undefined, undefined, signal);
|
|
107
|
+
if (response.statusCode === 204) {
|
|
108
|
+
await response.body.dump(); // Consume the body to avoid leaks
|
|
109
|
+
return;
|
|
114
110
|
}
|
|
111
|
+
throw await getResponseError(response, this.#path);
|
|
115
112
|
}
|
|
116
113
|
toString() {
|
|
117
114
|
return `S3File { path: "${this.#path}" }`;
|
package/dist/S3Stat.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import type { Headers } from "undici-types";
|
|
2
1
|
export default class S3Stat {
|
|
3
2
|
readonly etag: string;
|
|
4
3
|
readonly lastModified: Date;
|
|
5
4
|
readonly size: number;
|
|
6
5
|
readonly type: string;
|
|
7
6
|
constructor(etag: string, lastModified: Date, size: number, type: string);
|
|
8
|
-
static tryParseFromHeaders(headers:
|
|
7
|
+
static tryParseFromHeaders(headers: Record<string, string | string[] | undefined>): S3Stat | undefined;
|
|
9
8
|
}
|
package/dist/S3Stat.js
CHANGED
|
@@ -10,15 +10,15 @@ export default class S3Stat {
|
|
|
10
10
|
this.type = type;
|
|
11
11
|
}
|
|
12
12
|
static tryParseFromHeaders(headers) {
|
|
13
|
-
const lm = headers
|
|
14
|
-
if (lm === null) {
|
|
13
|
+
const lm = headers["last-modified"];
|
|
14
|
+
if (lm === null || typeof lm !== "string") {
|
|
15
15
|
return undefined;
|
|
16
16
|
}
|
|
17
|
-
const etag = headers.
|
|
18
|
-
if (etag === null) {
|
|
17
|
+
const etag = headers.etag;
|
|
18
|
+
if (etag === null || typeof etag !== "string") {
|
|
19
19
|
return undefined;
|
|
20
20
|
}
|
|
21
|
-
const cl = headers
|
|
21
|
+
const cl = headers["content-length"];
|
|
22
22
|
if (cl === null) {
|
|
23
23
|
return undefined;
|
|
24
24
|
}
|
|
@@ -26,8 +26,8 @@ export default class S3Stat {
|
|
|
26
26
|
if (!Number.isSafeInteger(size)) {
|
|
27
27
|
return undefined;
|
|
28
28
|
}
|
|
29
|
-
const ct = headers
|
|
30
|
-
if (ct === null) {
|
|
29
|
+
const ct = headers["content-type"];
|
|
30
|
+
if (ct === null || typeof ct !== "string") {
|
|
31
31
|
return undefined;
|
|
32
32
|
}
|
|
33
33
|
return new S3Stat(etag, new Date(lm), size, ct);
|
package/dist/error.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Dispatcher } from "undici";
|
|
2
|
+
import S3Error from "./S3Error.ts";
|
|
3
|
+
export declare function getResponseError(response: Dispatcher.ResponseData<unknown>, path: string): Promise<S3Error>;
|
|
4
|
+
export declare function fromStatusCode(code: number, path: string): S3Error | undefined;
|
package/dist/error.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { XMLParser } from "fast-xml-parser";
|
|
2
|
+
import S3Error from "./S3Error.js";
|
|
3
|
+
const xmlParser = new XMLParser();
|
|
4
|
+
export async function getResponseError(response, path) {
|
|
5
|
+
let body = undefined;
|
|
6
|
+
try {
|
|
7
|
+
body = await response.body.text();
|
|
8
|
+
}
|
|
9
|
+
catch (cause) {
|
|
10
|
+
return new S3Error("Unknown", path, {
|
|
11
|
+
message: "Could not read response body.",
|
|
12
|
+
cause,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
if (response.headers["content-type"] === "application/xml") {
|
|
16
|
+
return parseAndGetXmlError(body, path);
|
|
17
|
+
}
|
|
18
|
+
return new S3Error("Unknown", path, {
|
|
19
|
+
message: "Unknown error during S3 request.",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
export function fromStatusCode(code, path) {
|
|
23
|
+
switch (code) {
|
|
24
|
+
case 404:
|
|
25
|
+
return new S3Error("NoSuchKey", path, {
|
|
26
|
+
message: "The specified key does not exist.",
|
|
27
|
+
});
|
|
28
|
+
case 403:
|
|
29
|
+
return new S3Error("AccessDenied", path, {
|
|
30
|
+
message: "Access denied to the key.",
|
|
31
|
+
});
|
|
32
|
+
// TODO: Add more status codes as needed
|
|
33
|
+
default:
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function parseAndGetXmlError(body, path) {
|
|
38
|
+
let error = undefined;
|
|
39
|
+
try {
|
|
40
|
+
error = xmlParser.parse(body);
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
return new S3Error("Unknown", path, {
|
|
44
|
+
message: "Could not parse XML error response.",
|
|
45
|
+
cause,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (error.Error) {
|
|
49
|
+
const e = error.Error;
|
|
50
|
+
return new S3Error(e.Code || "Unknown", path, {
|
|
51
|
+
message: e.Message || undefined, // Message might be "",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return new S3Error(error.Code || "Unknown", path, {
|
|
55
|
+
message: error.Message || undefined, // Message might be "",
|
|
56
|
+
});
|
|
57
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -9,10 +9,18 @@ export type StorageClass = "STANDARD" | "DEEP_ARCHIVE" | "EXPRESS_ONEZONE" | "GL
|
|
|
9
9
|
export type ChecksumAlgorithm = "CRC32" | "CRC32C" | "CRC64NVME" | "SHA1" | "SHA256";
|
|
10
10
|
export type ChecksumType = "COMPOSITE" | "FULL_OBJECT";
|
|
11
11
|
export type PresignableHttpMethod = "GET" | "DELETE" | "PUT" | "HEAD";
|
|
12
|
-
export type HttpMethod = PresignableHttpMethod | "POST";
|
|
12
|
+
export type HttpMethod = PresignableHttpMethod | "POST"; // There are also others, but we don't want to support them yet
|
|
13
13
|
/** Body values supported by undici. */
|
|
14
14
|
export type UndiciBodyInit = string | Buffer | Uint8Array | Readable;
|
|
15
15
|
export type ByteSource = UndiciBodyInit | Blob;
|
|
16
|
+
// TODO
|
|
17
|
+
// | ArrayBufferView
|
|
18
|
+
// | ArrayBuffer
|
|
19
|
+
// | SharedArrayBuffer
|
|
20
|
+
// | Request
|
|
21
|
+
// | Response
|
|
22
|
+
// | S3File
|
|
23
|
+
// | ReadableStream<Uint8Array>
|
|
16
24
|
/**
|
|
17
25
|
* Implements [LocationInfo](https://docs.aws.amazon.com/AmazonS3/latest/API/API_LocationInfo.html)
|
|
18
26
|
*/
|
package/dist/sign.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type BinaryLike } from "node:crypto";
|
|
2
2
|
import type { AmzDate } from "./AmzDate.ts";
|
|
3
3
|
import type { HttpMethod, PresignableHttpMethod } from "./index.ts";
|
|
4
|
+
// Spec:
|
|
5
|
+
// https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html
|
|
4
6
|
export declare function deriveSigningKey(date: string, region: string, secretAccessKey: string): Buffer;
|
|
5
7
|
export declare function signCanonicalDataHash(signinKey: Buffer, canonicalDataHash: string, date: AmzDate, region: string): string;
|
|
6
8
|
export declare const unsignedPayload = "UNSIGNED-PAYLOAD";
|
package/dist/test-common.d.ts
CHANGED
|
@@ -1,4 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* @module Used by integration tests and unit tests.
|
|
3
|
-
*/
|
|
4
|
-
export declare function runTests(runId: number, endpoint: string, forcePathStyle: boolean, accessKeyId: string, secretAccessKey: string, region: string, bucket: string): void;
|
|
1
|
+
export declare function runTests(runId: number, endpoint: string, accessKeyId: string, secretAccessKey: string, region: string, bucket: string): void;
|
package/dist/test.integration.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
-
import { describe } from "node:test";
|
|
2
|
+
import { describe, before, after } from "node:test";
|
|
3
|
+
import { expect } from "expect";
|
|
3
4
|
import { runTests } from "./test-common.js";
|
|
5
|
+
import { S3Client } from "./index.js";
|
|
4
6
|
const env = process.env;
|
|
5
7
|
const runId = Date.now();
|
|
6
8
|
for (const provider of ["hetzner", "aws", "cloudflare"]) {
|
|
@@ -14,6 +16,29 @@ for (const provider of ["hetzner", "aws", "cloudflare"]) {
|
|
|
14
16
|
if (!endpoint || !region || !bucket || !accessKeyId || !secretAccessKey) {
|
|
15
17
|
throw new Error("Invalid config");
|
|
16
18
|
}
|
|
17
|
-
|
|
19
|
+
{
|
|
20
|
+
const client = new S3Client({
|
|
21
|
+
endpoint,
|
|
22
|
+
accessKeyId,
|
|
23
|
+
secretAccessKey,
|
|
24
|
+
region,
|
|
25
|
+
bucket,
|
|
26
|
+
});
|
|
27
|
+
before(async () => {
|
|
28
|
+
expect(await client.bucketExists(bucket)).toBe(true);
|
|
29
|
+
const objects = (await client.list({ prefix: `${runId}/` })).contents;
|
|
30
|
+
expect(objects.length).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
after(async () => {
|
|
33
|
+
expect(await client.bucketExists(bucket)).toBe(true);
|
|
34
|
+
const objects = (await client.list({ prefix: `${runId}/`, maxKeys: 1000 })).contents;
|
|
35
|
+
// clean up after all tests, but we want to fail because there are still objects
|
|
36
|
+
if (objects.length > 0) {
|
|
37
|
+
await client.deleteObjects(objects);
|
|
38
|
+
}
|
|
39
|
+
expect(objects.length).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
runTests(runId, endpoint, accessKeyId, secretAccessKey, region, bucket);
|
|
18
43
|
});
|
|
19
44
|
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "lean-s3",
|
|
3
3
|
"author": "Niklas Mollenhauer",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.2",
|
|
6
6
|
"description": "A server-side S3 API for the regular user.",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"s3",
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
"types": "./dist/index.d.ts",
|
|
20
20
|
"type": "module",
|
|
21
21
|
"scripts": {
|
|
22
|
-
"build": "
|
|
22
|
+
"build": "tsgo",
|
|
23
23
|
"clean": "rimraf dist",
|
|
24
|
-
"test": "
|
|
25
|
-
"test:integration": "
|
|
24
|
+
"test": "tsgo && node --test dist/*.test.js",
|
|
25
|
+
"test:integration": "tsgo && node --test dist/test.integration.js",
|
|
26
26
|
"ci": "biome ci ./src",
|
|
27
27
|
"docs": "typedoc",
|
|
28
28
|
"lint": "biome lint ./src",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"@testcontainers/localstack": "^11.0.3",
|
|
35
35
|
"@testcontainers/minio": "^11.0.3",
|
|
36
36
|
"@types/node": "^24.0.1",
|
|
37
|
+
"@typescript/native-preview": "^7.0.0-dev.20250613.1",
|
|
37
38
|
"expect": "^30.0.0",
|
|
38
39
|
"lefthook": "^1.11.13",
|
|
39
40
|
"rimraf": "^6.0.1",
|