lean-s3 0.1.1
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 +162 -0
- package/package.json +41 -0
- package/src/AmzDate.js +58 -0
- package/src/KeyCache.js +38 -0
- package/src/S3Client.js +576 -0
- package/src/S3Error.js +55 -0
- package/src/S3File.js +293 -0
- package/src/S3Stat.js +76 -0
- package/src/index.d.ts +80 -0
- package/src/index.js +4 -0
- package/src/sign.js +136 -0
- package/src/test-common.js +94 -0
- package/src/url.js +89 -0
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# lean-s3
|
|
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`.
|
|
4
|
+
|
|
5
|
+
## Elevator Pitch
|
|
6
|
+
```js
|
|
7
|
+
import { S3Client } from "lean-s3";
|
|
8
|
+
|
|
9
|
+
const client = new S3Client({
|
|
10
|
+
// All of these are _required_
|
|
11
|
+
// lean-s3 doesn't guess any of these. See below for common values for most providers
|
|
12
|
+
endpoint: env.S3_ENDPOINT,
|
|
13
|
+
accessKeyId: env.S3_ACCESS_KEY,
|
|
14
|
+
secretAccessKey: env.S3_SECRET_KEY,
|
|
15
|
+
region: "auto",
|
|
16
|
+
bucket: env.S3_BUCKET, // required here, but you can specify a different bucket later
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const test = client.file("/test.json");
|
|
20
|
+
|
|
21
|
+
const exists = await test.exists();
|
|
22
|
+
|
|
23
|
+
console.log("Does it exist?", exists);
|
|
24
|
+
|
|
25
|
+
if (exists) {
|
|
26
|
+
console.log("Seems so. How big is it?");
|
|
27
|
+
|
|
28
|
+
const stat = await test.stat();
|
|
29
|
+
console.log("Object size in bytes:", stat.size);
|
|
30
|
+
|
|
31
|
+
console.log("Its contents:");
|
|
32
|
+
console.log(await test.text()); // If it's JSON: `await test.json()`
|
|
33
|
+
|
|
34
|
+
// Delete the object:
|
|
35
|
+
// await test.delete();
|
|
36
|
+
|
|
37
|
+
// copy object to a different bucket in a different region
|
|
38
|
+
const otherFile = client.file("/new-file.json", {
|
|
39
|
+
bucket: "foo-bucket",
|
|
40
|
+
region: "bar-region",
|
|
41
|
+
});
|
|
42
|
+
await otherFile.write(test);
|
|
43
|
+
|
|
44
|
+
const firstBytesFile = test.slice(0, 100); // lazy-evaluated slicing
|
|
45
|
+
const firstBytes = await firstBytesFile.bytes(); // evaluated using HTTP range requests
|
|
46
|
+
console.log("First 100 bytes:", firstBytes);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log("Pre-signing URL for clients:");
|
|
50
|
+
|
|
51
|
+
const url = test.presign({ method: "PUT" }); // synchronous, no await needed
|
|
52
|
+
console.log(url);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Installation
|
|
56
|
+
```sh
|
|
57
|
+
# choose your PM
|
|
58
|
+
npm install lean-s3
|
|
59
|
+
yarn add lean-s3
|
|
60
|
+
pnpm add lean-s3
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Why?
|
|
64
|
+
[@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3) is cumbersome to use and doesn't align well with the current web standards. It is focused on providing a great experienced when used in conjunction with other AWS services. This comes at the cost of performance and package size:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
$ npm i @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
|
68
|
+
$ du -sh node_modules
|
|
69
|
+
21M node_modules
|
|
70
|
+
|
|
71
|
+
# vs
|
|
72
|
+
|
|
73
|
+
$ npm i lean-s3
|
|
74
|
+
$ du -sh node_modules
|
|
75
|
+
1,8M node_modules
|
|
76
|
+
```
|
|
77
|
+
`lean-s3` is _so_ lean that it is ~1.8MB just to do a couple of HTTP requests <img src="https://cdn.frankerfacez.com/emoticon/480839/1" width="20" height="20">
|
|
78
|
+
BUT...
|
|
79
|
+
|
|
80
|
+
Due to its scalability, portability and AWS integrations, pre-signing URLs is `async` and performs poorly in high-performance scenarios. By taking different trade-offs, lean-s3 can presign URLs much faster. I promise! This is the reason you cannot use lean-s3 in the browser.
|
|
81
|
+
|
|
82
|
+
lean-s3 is currently about 20x faster than AWS SDK when it comes to pre-signing URLs[^1]:
|
|
83
|
+
```
|
|
84
|
+
benchmark avg (min … max) p75 / p99
|
|
85
|
+
-------------------------------------------- ---------
|
|
86
|
+
@aws-sdk/s3-request-presigner 184.32 µs/iter 183.38 µs
|
|
87
|
+
(141.15 µs … 1.19 ms) 579.17 µs
|
|
88
|
+
(312.00 b … 5.07 mb) 233.20 kb
|
|
89
|
+
|
|
90
|
+
lean-s3 8.48 µs/iter 8.21 µs
|
|
91
|
+
(7.85 µs … 1.06 ms) 11.23 µs
|
|
92
|
+
(128.00 b … 614.83 kb) 5.26 kb
|
|
93
|
+
|
|
94
|
+
aws4fetch 65.49 µs/iter 62.83 µs
|
|
95
|
+
(52.43 µs … 1.01 ms) 158.99 µs
|
|
96
|
+
( 24.00 b … 1.42 mb) 53.38 kb
|
|
97
|
+
|
|
98
|
+
minio client 19.82 µs/iter 18.35 µs
|
|
99
|
+
(17.28 µs … 1.61 ms) 34.41 µs
|
|
100
|
+
(768.00 b … 721.07 kb) 16.18 kb
|
|
101
|
+
|
|
102
|
+
summary
|
|
103
|
+
lean-s3
|
|
104
|
+
2.34x faster than minio client
|
|
105
|
+
7.72x faster than aws4fetch
|
|
106
|
+
21.74x faster than @aws-sdk/s3-request-presigner
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Don't trust this benchmark and run it yourself[^2]. I am just some random internet guy trying to tell you [how much better this s3 client is](https://xkcd.com/927/). For `PUT` operations, it is ~1.45x faster than `@aws-sdk/client-s3`. We still work on improving these numbers.
|
|
110
|
+
|
|
111
|
+
## Why not lean-s3?
|
|
112
|
+
Don't use lean-s3 if you
|
|
113
|
+
- need a broader set of S3 operations.
|
|
114
|
+
- need a tight integration into the AWS ecosystem.
|
|
115
|
+
- need browser support.
|
|
116
|
+
- are already using `@aws-sdk/client-s3` and don't have any issues with it.
|
|
117
|
+
- are using Bun. Bun ships with a great built-in S3 API.
|
|
118
|
+
|
|
119
|
+
## I need feature X
|
|
120
|
+
We try to keep this library small. If you happen to need something that is not supported, maybe using the AWS SDK is an option for you. If you think that it is something that >80% of users of this library will need at some point, feel free to open an issue.
|
|
121
|
+
|
|
122
|
+
See [DESIGN_DECISIONS.md](./DESIGN_DECISIONS.md) to read about why this library is the way it is.
|
|
123
|
+
|
|
124
|
+
## Example Configurations
|
|
125
|
+
### Hetzner Object Storage
|
|
126
|
+
```js
|
|
127
|
+
const client = new S3Client({
|
|
128
|
+
endpoint: "https://fsn1.your-objectstorage.com", // "fsn1" may be different depending on your selected data center
|
|
129
|
+
region: "auto",
|
|
130
|
+
bucket: "<your-bucket-name>",
|
|
131
|
+
accessKeyId: process.env.S3_ACCESS_KEY_ID, // <your-access-key-id>,
|
|
132
|
+
secretAccessKey: process.env.S3_SECRET_KEY, // <your-secret-key>,
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Cloudflare R2
|
|
137
|
+
```js
|
|
138
|
+
const client = new S3Client({
|
|
139
|
+
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
|
|
140
|
+
region: "auto",
|
|
141
|
+
bucket: "<your-bucket-name>",
|
|
142
|
+
accessKeyId: process.env.S3_ACCESS_KEY_ID, // <your-access-key-id>,
|
|
143
|
+
secretAccessKey: process.env.S3_SECRET_KEY, // <your-secret-key>,
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Amazon AWS S3
|
|
148
|
+
```js
|
|
149
|
+
const client = new S3Client({
|
|
150
|
+
// keep {bucket} and {region} placeholders (they are used internally).
|
|
151
|
+
endpoint: "https://{bucket}.s3.{region}.amazonaws.com",
|
|
152
|
+
region: "<your-region>",
|
|
153
|
+
bucket: "<your-bucket-name>",
|
|
154
|
+
accessKeyId: process.env.S3_ACCESS_KEY_ID, // <your-access-key-id>,
|
|
155
|
+
secretAccessKey: process.env.S3_SECRET_KEY, // <your-secret-key>,
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Popular S3 provider missing? Open an issue or file a PR!
|
|
160
|
+
|
|
161
|
+
[^1]: Benchmark ran on a `13th Gen Intel(R) Core(TM) i7-1370P` using Node.js `23.11.0`. See `bench/` directory for the used benchmark.
|
|
162
|
+
[^2]: `git clone git@github.com:nikeee/lean-s3.git && cd lean-s3/bench && npm ci && npm start`
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lean-s3",
|
|
3
|
+
"author": "Niklas Mollenhauer",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"version": "0.1.1",
|
|
6
|
+
"description": "A server-side S3 API for the regular user.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"s3",
|
|
9
|
+
"client"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
"types": "./src/index.d.ts",
|
|
13
|
+
"default": "./src/index.js"
|
|
14
|
+
},
|
|
15
|
+
"types": "./src/index.d.ts",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test src/*.test.js",
|
|
19
|
+
"test:integration": "node --test integration/*.test.js",
|
|
20
|
+
"ci": "biome ci ./src",
|
|
21
|
+
"docs": "typedoc",
|
|
22
|
+
"lint": "biome lint ./src",
|
|
23
|
+
"format": "biome format --write ./src && biome lint --write ./src && biome check --write ./src"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@aws-sdk/client-s3": "^3.787.0",
|
|
27
|
+
"@biomejs/biome": "^1.9.4",
|
|
28
|
+
"@testcontainers/localstack": "^10.24.2",
|
|
29
|
+
"@testcontainers/minio": "^10.24.2",
|
|
30
|
+
"@types/node": "^22.14.1",
|
|
31
|
+
"expect": "^29.7.0",
|
|
32
|
+
"lefthook": "^1.11.10",
|
|
33
|
+
"typedoc": "^0.28.2"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": "^20.19.0 || ^22.14.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"undici": "^7.8.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/AmzDate.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
const ONE_DAY = 1000 * 60 * 60 * 24;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{
|
|
7
|
+
* numericDayStart: number;
|
|
8
|
+
* date: string;
|
|
9
|
+
* dateTime: string;
|
|
10
|
+
* }} AmzDate
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Date} dateTime
|
|
15
|
+
* @return {AmzDate}
|
|
16
|
+
*/
|
|
17
|
+
export function getAmzDate(dateTime) {
|
|
18
|
+
const date =
|
|
19
|
+
pad4(dateTime.getUTCFullYear()) +
|
|
20
|
+
pad2(dateTime.getUTCMonth() + 1) +
|
|
21
|
+
pad2(dateTime.getUTCDate());
|
|
22
|
+
|
|
23
|
+
const time =
|
|
24
|
+
pad2(dateTime.getUTCHours()) +
|
|
25
|
+
pad2(dateTime.getUTCMinutes()) +
|
|
26
|
+
pad2(dateTime.getUTCSeconds()); // it seems that we dont support milliseconds
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
numericDayStart: (dateTime.getTime() / ONE_DAY) | 0,
|
|
30
|
+
date,
|
|
31
|
+
dateTime: `${date}T${time}Z`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function now() {
|
|
35
|
+
return getAmzDate(new Date());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {number} v
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function pad4(v) {
|
|
43
|
+
return v < 10
|
|
44
|
+
? `000${v}`
|
|
45
|
+
: v < 100
|
|
46
|
+
? `00${v}`
|
|
47
|
+
: v < 1000
|
|
48
|
+
? `0${v}`
|
|
49
|
+
: v.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {number} v
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function pad2(v) {
|
|
57
|
+
return v < 10 ? `0${v}` : v.toString();
|
|
58
|
+
}
|
package/src/KeyCache.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import * as sign from "./sign.js";
|
|
4
|
+
|
|
5
|
+
/**@typedef {import("./AmzDate.js").AmzDate} AmzDate */
|
|
6
|
+
|
|
7
|
+
export default class KeyCache {
|
|
8
|
+
/** @type {number} */
|
|
9
|
+
#lastNumericDay = -1;
|
|
10
|
+
/** @type {Map<string, Buffer>} */
|
|
11
|
+
#keys = new Map();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {AmzDate} date
|
|
15
|
+
* @param {string} region
|
|
16
|
+
* @param {string} accessKeyId
|
|
17
|
+
* @param {string} secretAccessKey
|
|
18
|
+
* @returns {Buffer}
|
|
19
|
+
*/
|
|
20
|
+
computeIfAbsent(date, region, accessKeyId, secretAccessKey) {
|
|
21
|
+
if (date.numericDayStart !== this.#lastNumericDay) {
|
|
22
|
+
this.#keys.clear();
|
|
23
|
+
this.#lastNumericDay = date.numericDayStart;
|
|
24
|
+
// TODO: Add mechanism to clear the cache after some time
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// using accessKeyId to prevent keeping the secretAccessKey somewhere
|
|
28
|
+
const cacheKey = `${date.date}:${region}:${accessKeyId}`;
|
|
29
|
+
const key = this.#keys.get(cacheKey);
|
|
30
|
+
if (key) {
|
|
31
|
+
return key;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const newKey = sign.deriveSigningKey(date.date, region, secretAccessKey);
|
|
35
|
+
this.#keys.set(cacheKey, newKey);
|
|
36
|
+
return newKey;
|
|
37
|
+
}
|
|
38
|
+
}
|