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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # lean-s3 [![npm badge](https://img.shields.io/npm/v/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 that supports `fetch`.
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`
@@ -14,5 +14,6 @@ export default class S3BucketEntry {
14
14
  /**
15
15
  * @internal
16
16
  */
17
+ // biome-ignore lint/suspicious/noExplicitAny: internal use only, any is ok here
17
18
  static parse(source: any): S3BucketEntry;
18
19
  }
@@ -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
- export type CreateFileInstanceOptions = {};
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, }?: Partial<S3FilePresignOptions & OverridableS3ClientOptions>): string;
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.#signedRequest("POST", "", "delete=", // "=" is needed by minio for some reason
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
- if (name.length < 3 || name.length > 63) {
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.#signedRequest("PUT", "", undefined, body, additionalSignedHeaders, undefined, undefined, name, options?.signal);
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.#signedRequest("GET", "", query, undefined, undefined, undefined, undefined, this.#options.bucket, options.signal);
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
- async #signedRequest(method, pathWithoutBucket, query, body, additionalSignedHeaders, additionalUnsignedHeaders, contentHash, bucket, signal = undefined) {
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 url = buildRequestUrl(endpoint, bucket, region, pathWithoutBucket);
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
- async function getResponseError(response, path) {
573
- let body = undefined;
574
- try {
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 (response.headers["content-type"] === "application/xml") {
584
- return parseAndGetXmlError(body, path);
623
+ if (name.startsWith(".") || name.endsWith(".")) {
624
+ throw new Error("`name` must not start or end with a period (.)");
585
625
  }
586
- return new S3Error("Unknown", path, {
587
- message: "Unknown error during S3 request.",
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
- catch (cause) {
596
- return new S3Error("Unknown", path, {
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, }?: S3ErrorOptions);
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 {Error} If the file does not exist.
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, }?: Partial<S3FileExistsOptions>): Promise<boolean>;
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 {Error} If the file does not exist.
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
- // TODO: Don't use presign here
49
- const url = this.#client.presign(this.#path, { method: "HEAD" });
50
- const response = await fetch(url, { method: "HEAD", signal }); // TODO: Use undici
51
- if (!response.ok) {
52
- switch (response.status) {
53
- case 404:
54
- // TODO: Process response body
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
- const result = S3Stat.tryParseFromHeaders(response.headers);
62
- if (!result) {
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
- // TODO: Don't use presign here
75
- const url = this.#client.presign(this.#path, { method: "HEAD" });
76
- const res = await fetch(url, { method: "HEAD", signal }); // TODO: Use undici
77
- return res.ok;
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
- // TODO: Don't use presign here
103
- const url = this.#client.presign(this.#path, { method: "DELETE" });
104
- const response = await fetch(url, { method: "DELETE", signal }); // TODO: Use undici
105
- if (!response.ok) {
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: Headers): S3Stat | undefined;
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.get("last-modified");
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.get("etag");
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.get("content-length");
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.get("content-type");
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);
@@ -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";
@@ -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;
@@ -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
- runTests(runId, endpoint, false, accessKeyId, secretAccessKey, region, bucket);
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.1",
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": "tsc",
22
+ "build": "tsgo",
23
23
  "clean": "rimraf dist",
24
- "test": "tsc && node --test dist/*.test.js",
25
- "test:integration": "tsc && node --test dist/test.integration.js",
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",