@stowage/adapter-s3 0.0.0 → 0.1.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/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +2483 -0
- package/package.json +43 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Kaufmann
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# @stowage/adapter-s3
|
|
2
|
+
|
|
3
|
+
A storage in one bucket of AWS S3 or Cloudflare R2, spoken to over the S3 wire protocol rather than
|
|
4
|
+
through an SDK.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @stowage/adapter-s3
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Example
|
|
13
|
+
|
|
14
|
+
A server signs a URL for one upload of the type and length the browser reported. The browser then
|
|
15
|
+
calls it with a plain `fetch` and `PUT`, against AWS S3 or R2 alike.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { fromEnv, s3Storage } from "@stowage/adapter-s3";
|
|
19
|
+
|
|
20
|
+
declare const process: { readonly env: Readonly<Record<string, string | undefined>> };
|
|
21
|
+
|
|
22
|
+
const aws = s3Storage({ bucket: "my-app-uploads", region: "eu-north-1", credentials: fromEnv });
|
|
23
|
+
|
|
24
|
+
const fromR2Env = () => ({
|
|
25
|
+
accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
|
|
26
|
+
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const r2 = s3Storage({
|
|
30
|
+
bucket: "my-app-uploads",
|
|
31
|
+
region: "auto",
|
|
32
|
+
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
|
|
33
|
+
credentials: fromR2Env,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
for (const storage of [aws, r2]) {
|
|
37
|
+
const url = await storage.presignPut("avatars/alice.png", {
|
|
38
|
+
expiresIn: 300,
|
|
39
|
+
contentType: "image/png",
|
|
40
|
+
contentLength: 48_213,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
console.log(url);
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The bucket has to allow `UNSIGNED-PAYLOAD` and carry a CORS rule for the uploading origin; stowage
|
|
48
|
+
configures neither
|
|
49
|
+
([flow 2](https://github.com/stowage-js/stowage/blob/@stowage/adapter-s3@0.1.0/docs/spec.md#flow-2-browser-upload-through-a-presigned-put)).
|
|
50
|
+
|
|
51
|
+
## Runtimes
|
|
52
|
+
|
|
53
|
+
Node 24 and later, Bun, Deno and `workerd` at the compatibility date `2026-09-01`. `fromEnv` reads
|
|
54
|
+
the environment on `workerd` under `nodejs_compat` and on Deno under `--allow-env`. CI last ran green on Bun 1.4.2 and Deno 2.9.6.
|
|
55
|
+
|
|
56
|
+
The bundle measures 13.4 kB minified and gzipped, `@stowage/core` included.
|
|
57
|
+
|
|
58
|
+
## Limits
|
|
59
|
+
|
|
60
|
+
- `keyBytesPreserved` is not declared
|
|
61
|
+
([spec 4.9](https://github.com/stowage-js/stowage/blob/@stowage/adapter-s3@0.1.0/docs/spec.md#49-capabilities)).
|
|
62
|
+
R2 normalizes a key to NFC, so two Unicode-equivalent keys name one object there and two on AWS
|
|
63
|
+
S3.
|
|
64
|
+
- Where AWS S3 and R2 answer differently, the adapter is written to the stricter side
|
|
65
|
+
([spec 7.2](https://github.com/stowage-js/stowage/blob/@stowage/adapter-s3@0.1.0/docs/spec.md#72-promised-providers)):
|
|
66
|
+
|
|
67
|
+
| Point | What holds |
|
|
68
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
69
|
+
| Listing order | None. A page holds at most 1000 keys |
|
|
70
|
+
| `userMetadata` | 2 KB of encoded header bytes; ASCII keys |
|
|
71
|
+
| Single `PUT` | Up to 5 GB for a `Uint8Array` or string; a stream that fills more than one part goes as a multipart upload |
|
|
72
|
+
| Object size ceiling | The provider's, answered with `EntityTooLarge` |
|
|
73
|
+
| `Content-Type` | Always sent by `put`, `application/octet-stream` where none was given |
|
|
74
|
+
| `CompleteMultipartUpload` | Judged by its body, which may carry an error under `200` |
|
|
75
|
+
| Writes per key | R2 answers `429` above one write per second and key; the retry may recover a single collision, but does not guarantee it |
|
|
76
|
+
| Incomplete multipart uploads | Removed by a lifecycle rule on AWS, after seven days by default on R2; stowage removes none |
|
|
77
|
+
| Presigned URL host | The endpoint that signed it; on R2 the `r2.cloudflarestorage.com` endpoint and not a custom domain |
|
|
78
|
+
| Response overrides on `presignGet` | Answered as the four response headers, on AWS and on R2 |
|
|
79
|
+
|
|
80
|
+
## Notes
|
|
81
|
+
|
|
82
|
+
No package takes a connection URL
|
|
83
|
+
([spec 7.3](https://github.com/stowage-js/stowage/blob/@stowage/adapter-s3@0.1.0/docs/spec.md#73-credentials)).
|
|
84
|
+
A caller holding one splits it into the four options. `credentials: fromEnv` keeps the secret out of
|
|
85
|
+
the URL; one that stays in it has to be percent-encoded, because a `/` in the secret makes
|
|
86
|
+
`new URL` throw.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import { s3Storage, type S3Storage } from "@stowage/adapter-s3";
|
|
90
|
+
|
|
91
|
+
// s3://<access-key-id>:<secret-access-key>@<bucket>?region=<region>&endpoint=<endpoint>
|
|
92
|
+
export function s3StorageFromUrl(connection: string): S3Storage {
|
|
93
|
+
const url = new URL(connection);
|
|
94
|
+
const region = url.searchParams.get("region");
|
|
95
|
+
|
|
96
|
+
// The message quotes nothing of the URL, which carries the secret.
|
|
97
|
+
if (region === null) throw new Error("The connection URL names no region");
|
|
98
|
+
|
|
99
|
+
return s3Storage({
|
|
100
|
+
bucket: url.hostname,
|
|
101
|
+
region,
|
|
102
|
+
endpoint: url.searchParams.get("endpoint") ?? undefined,
|
|
103
|
+
credentials: {
|
|
104
|
+
accessKeyId: decodeURIComponent(url.username),
|
|
105
|
+
secretAccessKey: decodeURIComponent(url.password),
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`put` takes no `Blob`. A caller holding one passes its stream
|
|
112
|
+
([spec 4.2](https://github.com/stowage-js/stowage/blob/@stowage/adapter-s3@0.1.0/docs/spec.md#42-bodies)):
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { fromEnv, s3Storage } from "@stowage/adapter-s3";
|
|
116
|
+
|
|
117
|
+
const storage = s3Storage({ bucket: "my-app-uploads", region: "eu-north-1", credentials: fromEnv });
|
|
118
|
+
const blob = new Blob(["region,revenue\n"], { type: "text/csv" });
|
|
119
|
+
|
|
120
|
+
await storage.put("reports/2026/q3.csv", blob.stream(), { contentType: blob.type });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
stowage reports no progress, no upload id and no resume
|
|
124
|
+
([spec 7.6](https://github.com/stowage-js/stowage/blob/@stowage/adapter-s3@0.1.0/docs/spec.md#76-uploads)).
|
|
125
|
+
A caller who wants progress counts the bytes on their way into `put`:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { fromEnv, s3Storage } from "@stowage/adapter-s3";
|
|
129
|
+
|
|
130
|
+
const storage = s3Storage({ bucket: "my-app-uploads", region: "eu-north-1", credentials: fromEnv });
|
|
131
|
+
|
|
132
|
+
function countBytes(report: (bytes: number) => void): TransformStream<Uint8Array, Uint8Array> {
|
|
133
|
+
let bytes = 0;
|
|
134
|
+
|
|
135
|
+
return new TransformStream({
|
|
136
|
+
transform(chunk, controller) {
|
|
137
|
+
bytes += chunk.byteLength;
|
|
138
|
+
report(bytes);
|
|
139
|
+
controller.enqueue(chunk);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const response = await fetch("https://example.com/video.mp4");
|
|
145
|
+
|
|
146
|
+
if (response.body === null) throw new Error("The response carries no body");
|
|
147
|
+
|
|
148
|
+
await storage.put("videos/intro.mp4", response.body.pipeThrough(countBytes(console.log)), {
|
|
149
|
+
contentType: "video/mp4",
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Specification
|
|
154
|
+
|
|
155
|
+
[`docs/spec.md` at `@stowage/adapter-s3@0.1.0`](https://github.com/stowage-js/stowage/blob/@stowage/adapter-s3@0.1.0/docs/spec.md#7-stowageadapter-s3)
|
|
156
|
+
is the contract: a caller may rely on what it states and on nothing else this package happens to
|
|
157
|
+
export. The [terms it uses](https://github.com/stowage-js/stowage/blob/@stowage/adapter-s3@0.1.0/CONTEXT.md)
|
|
158
|
+
and the [decisions behind it](https://github.com/stowage-js/stowage/tree/@stowage/adapter-s3@0.1.0/docs/adr)
|
|
159
|
+
are at the same tag.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Resolvable, ResolverOptions, Storage } from "@stowage/core";
|
|
2
|
+
//#region src/credentials.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Checked before signing: `accessKeyId` and `secretAccessKey` are non-empty strings, and no
|
|
5
|
+
* key beyond these three is present. A violation is `InvalidCredentials` naming the field.
|
|
6
|
+
*/
|
|
7
|
+
interface S3Credentials {
|
|
8
|
+
accessKeyId: string;
|
|
9
|
+
secretAccessKey: string;
|
|
10
|
+
sessionToken?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A resolver, passed as `credentials: fromEnv` rather than called, so that a rotated
|
|
14
|
+
* `AWS_SESSION_TOKEN` reaches the next request. Reading the three variables again is
|
|
15
|
+
* what a refresh is here, so the options it is handed decide nothing (ADR 0007).
|
|
16
|
+
*/
|
|
17
|
+
export declare function fromEnv(_options?: ResolverOptions): S3Credentials;
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/configuration.d.ts
|
|
20
|
+
interface S3AdapterOptions {
|
|
21
|
+
bucket: string;
|
|
22
|
+
/** Sent as configured; nothing discovers it. R2 takes `"auto"`. */
|
|
23
|
+
region: string;
|
|
24
|
+
/**
|
|
25
|
+
* AWS S3 at `https://<bucket>.s3.<region>.amazonaws.com` where absent. Given, an absolute
|
|
26
|
+
* URL with no userinfo, no query and no fragment, `https:` always and `http:` only where the
|
|
27
|
+
* host is a loopback address; anything else is `InvalidOption` at construction.
|
|
28
|
+
*/
|
|
29
|
+
endpoint?: string;
|
|
30
|
+
/** Addresses the bucket in the path rather than in the host name, which is the default. */
|
|
31
|
+
forcePathStyle?: boolean;
|
|
32
|
+
credentials: Resolvable<S3Credentials>;
|
|
33
|
+
/**
|
|
34
|
+
* How often one HTTP request is attempted while its failure is transient: a response of
|
|
35
|
+
* `408`, `429` or `5xx`, or none at all. `false` sends one attempt. Neither switches off
|
|
36
|
+
* the one repeat with a fresh credential after the provider answered `Expired`.
|
|
37
|
+
*/
|
|
38
|
+
retry?: false | {
|
|
39
|
+
/** 1 to 3, and 3 where absent. Outside that range it is `InvalidOption`. */
|
|
40
|
+
maxAttempts?: number;
|
|
41
|
+
};
|
|
42
|
+
/** How a stream that fills more than one part is uploaded. */
|
|
43
|
+
multipart?: {
|
|
44
|
+
/**
|
|
45
|
+
* Bytes per part, 5 MiB to 5 GiB, and 8 MiB where absent. Outside that range it is
|
|
46
|
+
* `InvalidOption`. A stream needing more than 10,000 parts is `InvalidRequest`.
|
|
47
|
+
*/
|
|
48
|
+
partSize?: number;
|
|
49
|
+
/** Parts in flight, 1 to 16, and 4 where absent. Outside that range it is `InvalidOption`. */
|
|
50
|
+
concurrency?: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/presign.d.ts
|
|
55
|
+
interface S3PresignGetOptions {
|
|
56
|
+
/** Seconds, 1 to 604800. The credential that signs may cut the lifetime shorter. */
|
|
57
|
+
expiresIn: number;
|
|
58
|
+
responseContentType?: string;
|
|
59
|
+
responseContentDisposition?: string;
|
|
60
|
+
responseCacheControl?: string;
|
|
61
|
+
responseExpires?: string;
|
|
62
|
+
}
|
|
63
|
+
interface S3PresignPutOptions {
|
|
64
|
+
/** Seconds, 1 to 604800. The credential that signs may cut the lifetime shorter. */
|
|
65
|
+
expiresIn: number;
|
|
66
|
+
/** Bound exactly: an upload of another type is refused by the provider. */
|
|
67
|
+
contentType: string;
|
|
68
|
+
/**
|
|
69
|
+
* Bound exactly, so the client reports the length and the server signs that number: an
|
|
70
|
+
* upload of another length is refused, and there is no upper bound to sign instead. A
|
|
71
|
+
* finite, non-negative integer; anything else is `InvalidOption` before signing.
|
|
72
|
+
*/
|
|
73
|
+
contentLength: number;
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/index.d.ts
|
|
77
|
+
export interface S3Storage extends Storage {
|
|
78
|
+
readonly provider: "s3";
|
|
79
|
+
/**
|
|
80
|
+
* A URL a client holding no credential calls with a plain `GET` for this one key until
|
|
81
|
+
* it expires. Whoever holds it may read the object: it is a bearer token. Sends no
|
|
82
|
+
* request, and works against the endpoint that signed it only.
|
|
83
|
+
*/
|
|
84
|
+
presignGet(key: string, options: S3PresignGetOptions): Promise<string>;
|
|
85
|
+
/**
|
|
86
|
+
* A URL a client holding no credential calls with a plain `PUT` of one body under this
|
|
87
|
+
* key until it expires. `contentType` and `contentLength` bind exactly: the provider
|
|
88
|
+
* refuses a body of another type or another length, so a body of unknown length cannot
|
|
89
|
+
* be uploaded through it. It signs `UNSIGNED-PAYLOAD` and no checksum, so the upload
|
|
90
|
+
* carries no integrity check. Sends no request.
|
|
91
|
+
*/
|
|
92
|
+
presignPut(key: string, options: S3PresignPutOptions): Promise<string>;
|
|
93
|
+
}
|
|
94
|
+
export declare function s3Storage(options: S3AdapterOptions): S3Storage;
|
|
95
|
+
//#endregion
|
|
96
|
+
export type { S3AdapterOptions, S3Credentials, S3PresignGetOptions, S3PresignPutOptions };
|