lean-s3 0.8.12 → 0.9.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/README.md CHANGED
@@ -189,6 +189,19 @@ const client = new S3Client({
189
189
  });
190
190
  ```
191
191
 
192
+ ### Backblaze B2
193
+ ```js
194
+ // Docs: https://www.backblaze.com/apidocs/introduction-to-the-s3-compatible-api
195
+ const client = new S3Client({
196
+ // keep {region} placeholders (it is used internally).
197
+ endpoint: "https://s3.{region}.backblazeb2.com",
198
+ region: "<your-region>", // something like "eu-central-003"
199
+ bucket: "<your-bucket-name>",
200
+ accessKeyId: process.env.S3_ACCESS_KEY_ID, // <your-application-key-id>,
201
+ secretAccessKey: process.env.S3_SECRET_KEY, // <your-application-key>,
202
+ });
203
+ ```
204
+
192
205
  Popular S3 provider missing? Open an issue or file a PR!
193
206
 
194
207
  ## Tested On
@@ -197,6 +210,9 @@ To ensure compability across various providers and self-hosted services, all tes
197
210
  - Hetzner Object Storage
198
211
  - Cloudflare R2
199
212
  - Garage
213
+ - Backblaze B2
200
214
  - Minio
201
215
  - LocalStack
216
+ - RustFS
202
217
  - Ceph
218
+ - S3Mock
@@ -1,3 +1,4 @@
1
+ import * as nodeUtil from "node:util";
1
2
  import { Readable } from "node:stream";
2
3
  import { Dispatcher } from "undici";
3
4
 
@@ -97,7 +98,10 @@ type DeleteObjectsError = {
97
98
  };
98
99
  interface S3FilePresignOptions extends OverridableS3ClientOptions {
99
100
  contentHash?: Buffer;
100
- /** Seconds. */
101
+ /**
102
+ * In seconds.
103
+ * @default 3600 (1 hour)
104
+ */
101
105
  expiresIn?: number;
102
106
  method?: PresignableHttpMethod;
103
107
  contentLength?: number;
@@ -127,6 +131,25 @@ interface S3FilePresignOptions extends OverridableS3ClientOptions {
127
131
  contentDisposition?: ContentDisposition;
128
132
  };
129
133
  }
134
+ /**
135
+ * Ref: https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html#sigv4-ConditionMatching
136
+ */
137
+ type ConditionMatchType = "starts-with" | "eq" | "content-length-range";
138
+ type PostPolicyCondition = [string, string] | [ConditionMatchType, string | number, string | number];
139
+ interface PresignPostOptions extends OverridableS3ClientOptions {
140
+ key: string;
141
+ /**
142
+ * In seconds.
143
+ * @default 3600 (1 hour)
144
+ */
145
+ expiresIn?: number;
146
+ fields?: Record<string, string>;
147
+ conditions?: PostPolicyCondition[];
148
+ }
149
+ type PresignPostResult = {
150
+ url: string;
151
+ fields: Record<string, string>;
152
+ };
130
153
  type CopyObjectOptions = {
131
154
  /** Set this to override the {@link S3ClientOptions#bucket} that was passed on creation of the {@link S3Client}. */
132
155
  sourceBucket?: string;
@@ -386,7 +409,7 @@ declare class S3Client {
386
409
  /**
387
410
  * 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.
388
411
  *
389
- * @param options The default options to use for the S3 client.
412
+ * @param options The default options to use for the S3 client.
390
413
  */
391
414
  constructor(options: S3ClientOptions);
392
415
  /** @internal */
@@ -451,6 +474,7 @@ declare class S3Client {
451
474
  * ```
452
475
  */
453
476
  presign(path: string, options?: S3FilePresignOptions): string;
477
+ presignPost(options: PresignPostOptions): PresignPostResult;
454
478
  /**
455
479
  * Copies an object from a source to a destination.
456
480
  * @remarks Uses [`CopyObject`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html).
@@ -557,6 +581,7 @@ declare class S3Client {
557
581
  * @internal
558
582
  */
559
583
  [kStream](path: ObjectKey, contentHash: Buffer | undefined, rageStart: number | undefined, rangeEndExclusive: number | undefined): ReadableStream<Uint8Array>;
584
+ [nodeUtil.inspect.custom](_depth?: number, options?: nodeUtil.InspectOptions): string;
560
585
  }
561
586
  //#endregion
562
587
  //#region src/S3Stat.d.ts
@@ -643,6 +668,7 @@ declare class S3File {
643
668
  * @param options Options for the copy operation.
644
669
  */
645
670
  copyTo(destination: string, options?: CopyObjectOptions): Promise<void>;
671
+ [nodeUtil.inspect.custom](_depth?: number, options?: nodeUtil.InspectOptions): string;
646
672
  }
647
673
  interface S3FileDeleteOptions extends OverridableS3ClientOptions {
648
674
  /** Signal to abort the request. */
@@ -666,22 +692,21 @@ type S3FileWriteOptions = {
666
692
  //#region src/S3Error.d.ts
667
693
  declare class S3Error extends Error {
668
694
  readonly code: string;
695
+ /** Path/key of the affected object. */
669
696
  readonly path: string;
670
697
  readonly message: string;
671
- readonly requestId: string | undefined;
672
- readonly hostId: string | undefined;
698
+ /** The HTTP status code. */
699
+ readonly status: number | undefined;
673
700
  constructor(code: string, path: string, {
674
701
  message,
675
- requestId,
676
- hostId,
677
- cause
702
+ cause,
703
+ status
678
704
  }?: S3ErrorOptions);
679
705
  }
680
706
  type S3ErrorOptions = {
681
707
  message?: string | undefined;
682
- requestId?: string | undefined;
683
- hostId?: string | undefined;
684
708
  cause?: unknown;
709
+ status?: number;
685
710
  };
686
711
  //#endregion
687
712
  //#region src/index.d.ts
@@ -1,3 +1,4 @@
1
+ import * as nodeUtil from "node:util";
1
2
  import { Readable } from "node:stream";
2
3
  import { Agent, request } from "undici";
3
4
  import { XMLBuilder, XMLParser } from "fast-xml-parser";
@@ -34,17 +35,17 @@ var S3Stat = class S3Stat {
34
35
  //#region src/S3Error.ts
35
36
  var S3Error = class extends Error {
36
37
  code;
38
+ /** Path/key of the affected object. */
37
39
  path;
38
40
  message;
39
- requestId;
40
- hostId;
41
- constructor(code, path, { message = void 0, requestId = void 0, hostId = void 0, cause = void 0 } = {}) {
41
+ /** The HTTP status code. */
42
+ status;
43
+ constructor(code, path, { message = void 0, cause = void 0, status = void 0 } = {}) {
42
44
  super(message, { cause });
43
45
  this.code = code;
44
46
  this.path = path;
45
47
  this.message = message ?? "Some unknown error occurred.";
46
- this.requestId = requestId;
47
- this.hostId = hostId;
48
+ this.status = status;
48
49
  }
49
50
  };
50
51
 
@@ -83,8 +84,11 @@ var S3BucketEntry = class S3BucketEntry {
83
84
  function deriveSigningKey(date, region, secretAccessKey) {
84
85
  return createHmac("sha256", createHmac("sha256", createHmac("sha256", createHmac("sha256", `AWS4${secretAccessKey}`).update(date).digest()).update(region).digest()).update("s3").digest()).update("aws4_request").digest();
85
86
  }
86
- function signCanonicalDataHash(signinKey, canonicalDataHash, date, region) {
87
- return createHmac("sha256", signinKey).update(`AWS4-HMAC-SHA256\n${date.dateTime}\n${date.date}/${region}/s3/aws4_request\n${canonicalDataHash}`).digest("hex");
87
+ function signEncodedPolicy(signingKey, encodedPolicy) {
88
+ return createHmac("sha256", signingKey).update(encodedPolicy).digest("hex");
89
+ }
90
+ function signCanonicalDataHash(signingKey, canonicalDataHash, date, region) {
91
+ return createHmac("sha256", signingKey).update(`AWS4-HMAC-SHA256\n${date.dateTime}\n${date.date}/${region}/s3/aws4_request\n${canonicalDataHash}`).digest("hex");
88
92
  }
89
93
  const unsignedPayload = "UNSIGNED-PAYLOAD";
90
94
  /**
@@ -345,7 +349,7 @@ var S3Client = class {
345
349
  /**
346
350
  * 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.
347
351
  *
348
- * @param options The default options to use for the S3 client.
352
+ * @param options The default options to use for the S3 client.
349
353
  */
350
354
  constructor(options) {
351
355
  if (!options) throw new Error("`options` is required.");
@@ -456,6 +460,76 @@ var S3Client = class {
456
460
  res.search = `${query}&X-Amz-Signature=${signCanonicalDataHash(signingKey, dataDigest, date, region)}`;
457
461
  return res.toString();
458
462
  }
463
+ presignPost(options) {
464
+ const now$1 = /* @__PURE__ */ new Date();
465
+ const date = getAmzDate(now$1);
466
+ const key = options.key;
467
+ const region = ensureValidRegion(options.region ?? this.#options.region);
468
+ const bucket = ensureValidBucketName(options.bucket ?? this.#options.bucket);
469
+ const endpoint = ensureValidEndpoint(options.endpoint ?? this.#options.endpoint);
470
+ const expiresIn = options.expiresIn ?? 3600;
471
+ const credential = `${this.#options.accessKeyId}/${date.date}/${region}/s3/aws4_request`;
472
+ const fields = {
473
+ ...options.fields,
474
+ bucket,
475
+ "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
476
+ "X-Amz-Credential": credential,
477
+ "X-Amz-Date": date.dateTime,
478
+ ...this.#options.sessionToken ? { "X-Amz-Security-Token": this.#options.sessionToken } : void 0
479
+ };
480
+ const policy = {
481
+ expiration: new Date(now$1.getTime() + expiresIn * 1e3).toISOString().replace(/\.\d{3}Z$/, "Z"),
482
+ conditions: [
483
+ [
484
+ "eq",
485
+ "$bucket",
486
+ bucket
487
+ ],
488
+ key.endsWith("{{filename}}") ? [
489
+ "starts-with",
490
+ "$key",
491
+ key.substring(0, key.lastIndexOf("{{filename}}"))
492
+ ] : [
493
+ "eq",
494
+ "$key",
495
+ key
496
+ ],
497
+ ...options.conditions ? options.conditions : [],
498
+ [
499
+ "eq",
500
+ "$x-amz-algorithm",
501
+ "AWS4-HMAC-SHA256"
502
+ ],
503
+ [
504
+ "eq",
505
+ "$x-amz-credential",
506
+ credential
507
+ ],
508
+ [
509
+ "eq",
510
+ "$x-amz-date",
511
+ date.dateTime
512
+ ]
513
+ ]
514
+ };
515
+ if (this.#options.sessionToken) policy.conditions.push([
516
+ "eq",
517
+ "$x-amz-security-token",
518
+ this.#options.sessionToken
519
+ ]);
520
+ const policyJson = JSON.stringify(policy);
521
+ const encodedPolicy = Buffer.from(policyJson).toString("base64");
522
+ const signingKey = this.#keyCache.computeIfAbsent(date, region, this.#options.accessKeyId, this.#options.secretAccessKey);
523
+ return {
524
+ url: buildRequestUrl(endpoint, bucket, region, "").toString(),
525
+ fields: {
526
+ ...fields,
527
+ key,
528
+ Policy: encodedPolicy,
529
+ "X-Amz-Signature": signEncodedPolicy(signingKey, encodedPolicy)
530
+ }
531
+ };
532
+ }
459
533
  /**
460
534
  * Copies an object from a source to a destination.
461
535
  * @remarks Uses [`CopyObject`](https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html).
@@ -977,12 +1051,17 @@ var S3Client = class {
977
1051
  } catch (cause) {
978
1052
  return controller.error(new S3Error("Unknown", path, {
979
1053
  message: "Could not parse XML error response.",
1054
+ status: response.statusCode,
980
1055
  cause
981
1056
  }));
982
1057
  }
983
- return controller.error(new S3Error(error.Error.Code || "Unknown", path, { message: error.Error.Message || void 0 }));
1058
+ return controller.error(new S3Error(error.Error.Code || "Unknown", path, {
1059
+ message: error.Error.Message || void 0,
1060
+ status: response.statusCode
1061
+ }));
984
1062
  }, onNetworkError);
985
1063
  return controller.error(new S3Error("Unknown", path, {
1064
+ status: response.statusCode,
986
1065
  message: void 0,
987
1066
  cause: responseText
988
1067
  }));
@@ -995,6 +1074,17 @@ var S3Client = class {
995
1074
  }
996
1075
  });
997
1076
  }
1077
+ [nodeUtil.inspect.custom](_depth, options = {}) {
1078
+ if (options.depth === null) options.depth = 2;
1079
+ options.colors ??= true;
1080
+ const properties = {
1081
+ endpoint: this.#options.endpoint,
1082
+ bucket: this.#options.bucket,
1083
+ region: this.#options.region,
1084
+ accessKeyId: this.#options.accessKeyId
1085
+ };
1086
+ return `S3Client ${nodeUtil.formatWithOptions(options, properties)}`;
1087
+ }
998
1088
  };
999
1089
  function buildSearchParams(amzCredential, date, expiresIn, headerList, contentHashStr, storageClass, sessionToken, acl, responseContentDisposition) {
1000
1090
  let res = "";
@@ -1179,6 +1269,17 @@ var S3File = class S3File {
1179
1269
  async copyTo(destination, options = {}) {
1180
1270
  await this.#client.copyObject(this.#path, destination, options);
1181
1271
  }
1272
+ [nodeUtil.inspect.custom](_depth, options = {}) {
1273
+ if (options.depth === null) options.depth = 2;
1274
+ options.colors ??= true;
1275
+ const properties = {
1276
+ path: this.#path,
1277
+ start: this.#start,
1278
+ end: this.#end,
1279
+ contentType: this.#contentType
1280
+ };
1281
+ return `S3File ${nodeUtil.formatWithOptions(options, properties)}`;
1282
+ }
1182
1283
  };
1183
1284
 
1184
1285
  //#endregion
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.8.12",
5
+ "version": "0.9.0",
6
6
  "description": "A server-side S3 API for the regular user.",
7
7
  "keywords": [
8
8
  "s3",
@@ -26,12 +26,12 @@
26
26
  "type": "git",
27
27
  "url": "https://github.com/nikeee/lean-s3"
28
28
  },
29
- "exports": {
30
- "types": "./dist/index.d.ts",
31
- "default": "./dist/index.js"
32
- },
33
- "types": "./dist/index.d.ts",
29
+ "types": "./dist/index.d.mts",
30
+ "main": "./dist/index.mjs",
34
31
  "type": "module",
32
+ "files": [
33
+ "dist"
34
+ ],
35
35
  "scripts": {
36
36
  "build": "tsdown",
37
37
  "test": "tsgo && tsx --test src/*.test.ts src/test/*.test.ts",
@@ -39,27 +39,28 @@
39
39
  "ci": "biome ci ./src",
40
40
  "docs": "typedoc",
41
41
  "lint": "biome lint ./src",
42
- "format": "biome format --write ./src && biome lint --write ./src && biome check --write ./src",
42
+ "format": "biome check --write ./src",
43
43
  "prepublishOnly": "npm run build"
44
44
  },
45
45
  "dependencies": {
46
- "fast-xml-parser": "^5.3.0",
46
+ "fast-xml-parser": "^5.3.2",
47
47
  "undici": "^7.16.0"
48
48
  },
49
49
  "devDependencies": {
50
- "@biomejs/biome": "2.3.2",
51
- "@testcontainers/localstack": "^11.7.2",
52
- "@testcontainers/minio": "^11.7.2",
53
- "@types/node": "^24.9.2",
54
- "@typescript/native-preview": "^7.0.0-dev.20251029.1",
50
+ "@biomejs/biome": "2.3.6",
51
+ "@testcontainers/localstack": "^11.8.1",
52
+ "@testcontainers/minio": "^11.8.1",
53
+ "@testcontainers/s3mock": "^11.8.1",
54
+ "@types/node": "^24.10.1",
55
+ "@typescript/native-preview": "^7.0.0-dev.20251118.1",
55
56
  "expect": "^30.2.0",
56
- "lefthook": "^2.0.2",
57
- "testcontainers": "^11.7.2",
58
- "tsdown": "^0.15.12",
57
+ "lefthook": "^2.0.4",
58
+ "testcontainers": "^11.8.1",
59
+ "tsdown": "^0.16.5",
59
60
  "tsx": "^4.20.6",
60
61
  "typedoc": "^0.28.14"
61
62
  },
62
63
  "engines": {
63
- "node": "^20.19.3 || ^22.17.0 || ^24.4.0 || ^25"
64
+ "node": "^20.19.5 || ^22.21.1 || ^24.11.0 || ^25.1.0"
64
65
  }
65
66
  }
package/tsdown.config.ts DELETED
@@ -1,6 +0,0 @@
1
- import { defineConfig } from "tsdown";
2
-
3
- export default defineConfig({
4
- entry: ["src/index.ts"],
5
- target: "esnext",
6
- });