@nxgt/s3 0.2.0 → 0.2.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 CHANGED
@@ -34,12 +34,15 @@ everything this package does not wrap is still there.
34
34
  ## Install
35
35
 
36
36
  ```sh
37
- bun add @nxgt/s3 typescript
37
+ bun add @nxgt/s3 typescript @types/bun
38
38
  ```
39
39
 
40
40
  - **Bun 1.4 or later, and Bun only.** `S3Client` is built into Bun, which is
41
41
  why there is no SDK to install — and why this package does not run on Node.
42
42
  - `typescript` `^6.0.3`: required peer, the version every `@nxgt` package pins.
43
+ - `@types/bun`: required to typecheck. The shipped declarations name Bun's own
44
+ `S3Client`, `S3File` and `S3Options`, so without Bun's types the first `tsc`
45
+ fails with `Cannot find module 'bun'`.
43
46
  - Tested against SeaweedFS 4.47's S3 gateway. Anything S3-compatible that Bun
44
47
  can sign for will do; a service that is not AWS wants
45
48
  `virtualHostedStyle: false`.
@@ -265,6 +268,15 @@ Each is a `@ts-expect-error` case in `test/types/s3.ts`.
265
268
  `undefined`; every other failure — a wrong secret, a refused request, a
266
269
  service that is down — comes back as the error it is.
267
270
 
271
+ ## Documentation
272
+
273
+ - [docs/README.md](docs/README.md) — the guide index: buckets, reading,
274
+ writing and presigned URLs, each with its options and a worked example.
275
+ - [docs/troubleshooting.md](docs/troubleshooting.md) — every error this
276
+ package can raise, by the message you will see.
277
+ - [docs/roadmap.md](docs/roadmap.md) — what is coming, and what has been
278
+ ruled out.
279
+
268
280
  ## License
269
281
 
270
282
  MIT
package/docs/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # `@nxgt/s3` documentation
2
+
3
+ S3 on Bun's own `S3Client`. `S3Client` is built into Bun, so this package has
4
+ **no dependency at all** — no AWS SDK — and does not run on Node.
5
+
6
+ | Page | Read it when |
7
+ | --- | --- |
8
+ | [Buckets](guide/buckets.md) | you are describing a bucket, building its keys, or binding it to credentials |
9
+ | [Reading](guide/reads.md) | you want an object's bytes, its text, whether it is there, what the service knows about it, or a page of the bucket |
10
+ | [Writing](guide/writes.md) | you are storing an object, choosing what a write says about it, or handling a refusal |
11
+ | [Presigned URLs](guide/presigned-urls.md) | a browser or another service should read or write an object directly, without your credentials |
12
+ | [Troubleshooting](troubleshooting.md) | a call threw, an upload was refused, or a signed URL did not do what you expected |
13
+ | [Roadmap](roadmap.md) | you want to know what is coming, and what has been ruled out |
14
+
15
+ The [README](../README.md) is the short version: install, one example per
16
+ area, and the traps in one line each.
@@ -0,0 +1,170 @@
1
+ # Buckets
2
+
3
+ Describing a bucket once — which bucket, how a key is built, and what this
4
+ application is willing to put there — then binding that description to
5
+ credentials.
6
+
7
+ ## The smallest thing that works
8
+
9
+ ```ts
10
+ import { bindBucket, defineBucket } from '@nxgt/s3';
11
+
12
+ export const avatars = defineBucket({
13
+ bucket: 'avatars',
14
+ key: (p: { userId: string }) => `${p.userId}.png`,
15
+ contentType: ['image/png', 'image/jpeg'],
16
+ maxSize: 2 * 1024 * 1024, // bytes
17
+ });
18
+
19
+ const store = bindBucket(avatars, {
20
+ endpoint: process.env.S3_ENDPOINT,
21
+ accessKeyId: process.env.S3_KEY,
22
+ secretAccessKey: process.env.S3_SECRET,
23
+ });
24
+
25
+ store.keyFor({ userId: 'u1' }); // 'u1.png'
26
+ ```
27
+
28
+ `defineBucket` talks to nothing, so the definition is what a server, a worker
29
+ and a test import; `bindBucket` is the half that needs credentials.
30
+ `S3Client` is built into Bun, which is why there is no SDK in `bun add` — and
31
+ why this package does not run on Node.
32
+
33
+ ## The signatures
34
+
35
+ ```ts
36
+ import type { S3Client, S3File, S3Options, S3Stats } from 'bun';
37
+
38
+ interface BucketDefinition<P> {
39
+ readonly bucket: string;
40
+ readonly key: (params: P) => string;
41
+ readonly contentType?: string | readonly string[];
42
+ readonly maxSize?: number;
43
+ }
44
+
45
+ function defineBucket<P>(definition: BucketDefinition<P>): BucketDefinition<P>;
46
+
47
+ function bindBucket<P>(
48
+ definition: BucketDefinition<P>,
49
+ options?: Omit<S3Options, 'bucket'>,
50
+ ): BoundBucket<P>;
51
+
52
+ interface BoundBucket<P> {
53
+ readonly client: S3Client;
54
+ keyFor(params: P): string;
55
+ file(params: P): S3File;
56
+ put(params: P, body: PutBody, options?: PutOptions): Promise<void>;
57
+ bytes(params: P): Promise<Uint8Array | undefined>;
58
+ text(params: P): Promise<string | undefined>;
59
+ exists(params: P): Promise<boolean>;
60
+ stat(params: P): Promise<S3Stats | undefined>;
61
+ delete(params: P): Promise<void>;
62
+ list(options?: {
63
+ prefix?: string;
64
+ limit?: number;
65
+ cursor?: string | null;
66
+ }): Promise<ObjectPage>;
67
+ presignGet(params: P, options?: PresignOptions): string;
68
+ presignPut(params: P, options?: PresignOptions): string;
69
+ }
70
+ ```
71
+
72
+ ## The definition
73
+
74
+ | Option | Type | Default | Effect |
75
+ | --- | --- | --- | --- |
76
+ | `bucket` | `string` | required | the bucket's name on the service |
77
+ | `key` | `(params: P) => string` | required | the object's key, from whatever identifies it |
78
+ | `contentType` | `string \| readonly string[]` | anything goes | the content types this bucket accepts; a write of anything else is refused **before it is sent** |
79
+ | `maxSize` | `number` | anything goes | the biggest body, in **bytes**, refused before it is sent |
80
+
81
+ The key is a **function**, not a template, so nothing is spelled by hand at a
82
+ call site and a renamed parameter is a compile error. `P` is whatever that
83
+ function takes:
84
+
85
+ ```ts
86
+ const uploads = defineBucket({
87
+ bucket: 'uploads',
88
+ key: (p: { folder: string; name: string }) => `${p.folder}/${p.name}`,
89
+ });
90
+
91
+ bindBucket(uploads).keyFor({ folder: 'a', name: 'b.txt' }); // 'a/b.txt'
92
+ ```
93
+
94
+ `keyFor` exists so a caller that needs the string gets *the* string. Building
95
+ one by hand somewhere else is how a bucket ends up with two spellings of the
96
+ same object.
97
+
98
+ What `contentType` and `maxSize` do is [Writing](writes.md); they are guards
99
+ on `put`, and nothing else on the object goes through them.
100
+
101
+ `defineBucket` refuses a definition that could never work, at import time:
102
+
103
+ ```ts
104
+ defineBucket({ bucket: '', key: () => 'k' });
105
+ // TypeError: defineBucket: a bucket definition needs a bucket
106
+
107
+ defineBucket({ bucket: 'b', key: () => 'k', maxSize: 0 });
108
+ // TypeError: defineBucket: "b" has a maxSize of 0; it is a number of bytes…
109
+
110
+ defineBucket({ bucket: 'b', key: () => 'k', contentType: [] });
111
+ // TypeError: … accepts an empty list of content types, so nothing could ever
112
+ // be written. Leave `contentType` out to accept anything
113
+ ```
114
+
115
+ The definition it gives back is frozen, so it cannot drift once it is shared.
116
+
117
+ ## Binding it
118
+
119
+ `options` is Bun's own `S3Options` without `bucket` — the bucket comes from
120
+ the definition, and a call may never change it. It is optional: given none,
121
+ Bun reads its own `S3_*` / `AWS_*` environment variables.
122
+
123
+ ```ts
124
+ const store = bindBucket(avatars); // credentials from the environment
125
+ ```
126
+
127
+ | Option | Type | Default | Effect |
128
+ | --- | --- | --- | --- |
129
+ | `endpoint` | `string` | AWS | the service's URL; anything S3-compatible Bun can sign for |
130
+ | `accessKeyId` / `secretAccessKey` | `string` | `$S3_*` / `$AWS_*` | the credentials |
131
+ | `sessionToken` | `string` | `$AWS_SESSION_TOKEN` | for temporary credentials |
132
+ | `region` | `string` | `$S3_REGION` / `$AWS_REGION` | the region to sign for |
133
+ | `virtualHostedStyle` | `boolean` | `false` | `bucket.host` URLs instead of `host/bucket`; a service that is not AWS usually wants it left off |
134
+ | `acl`, `storageClass` | see [Writing](writes.md) | — | a default for every object this client writes |
135
+ | `retry`, `partSize`, `queueSize` | `number` | Bun's | the client's own transfer tuning. A [`put`](writes.md) takes none of these: it is one PUT |
136
+
137
+ Each bound bucket holds an `S3Client` of its own. S3 is stateless HTTP —
138
+ there is no connection to share, and **nothing to close**.
139
+
140
+ ## Everything this package does not wrap
141
+
142
+ ```ts
143
+ store.client; // Bun's S3Client
144
+ store.file({ userId: 'u1' }); // Bun's lazy S3File
145
+ ```
146
+
147
+ `file(params)` is the handle for `.stream()`, `.slice()`, `.writer()` and the
148
+ rest, keyed by the definition rather than by a string you typed. It is also
149
+ the way to write a body in parts, which `put` does not do.
150
+
151
+ ## Types a caller names
152
+
153
+ ```ts
154
+ import type { BoundBucket, ParamsOf } from '@nxgt/s3';
155
+
156
+ type AvatarParams = ParamsOf<typeof avatars>; // { userId: string }
157
+
158
+ async function purge(bucket: BoundBucket<AvatarParams>, userId: string) {
159
+ await bucket.delete({ userId });
160
+ }
161
+ ```
162
+
163
+ `stat` gives back Bun's own `S3Stats`, which this package does not re-export:
164
+ import it from `bun` where you need to name it.
165
+
166
+ ## Next
167
+
168
+ - [Writing](writes.md) — `put`, its options, and the guards.
169
+ - [Reading](reads.md) — `bytes`, `text`, `stat`, `exists` and `list`.
170
+ - [Presigned URLs](presigned-urls.md) — the same definition, signed.
@@ -0,0 +1,153 @@
1
+ # Presigned URLs
2
+
3
+ Handing a browser or another service a URL that reads or writes one object,
4
+ for a while, without ever handing over a credential.
5
+
6
+ ## The smallest thing that works
7
+
8
+ ```ts
9
+ import { bindBucket, defineBucket } from '@nxgt/s3';
10
+
11
+ const avatars = defineBucket({
12
+ bucket: 'avatars',
13
+ key: (p: { userId: string }) => `${p.userId}.png`,
14
+ contentType: ['image/png', 'image/jpeg'],
15
+ maxSize: 2 * 1024 * 1024,
16
+ });
17
+
18
+ const store = bindBucket(avatars);
19
+
20
+ const download = store.presignGet({ userId: 'u1' }, { expiresIn: 300 }); // seconds
21
+ const upload = store.presignPut({ userId: 'u1' }, { expiresIn: 300 });
22
+ ```
23
+
24
+ Both are synchronous: signing is arithmetic, not a request. The key comes
25
+ from the definition, so a signed URL and a `put` can never disagree about
26
+ which object they mean — see [Buckets](buckets.md).
27
+
28
+ ## The signatures
29
+
30
+ ```ts
31
+ import type { S3Options } from 'bun';
32
+
33
+ presignGet(params: P, options?: PresignOptions): string;
34
+ presignPut(params: P, options?: PresignOptions): string;
35
+
36
+ interface PresignOptions {
37
+ expiresIn?: number;
38
+ acl?: S3Options['acl'];
39
+ }
40
+ ```
41
+
42
+ ## Options
43
+
44
+ | Option | Type | Default | Effect |
45
+ | --- | --- | --- | --- |
46
+ | `expiresIn` | `number` | Bun's, one day | **seconds** until the URL expires. Always pass one |
47
+ | `acl` | `'private' \| 'public-read' \| …` | none | the ACL the URL is signed for, where the service honours it |
48
+
49
+ A day is a long time for a URL that anyone can forward. Sign for the time the
50
+ page actually needs:
51
+
52
+ ```ts
53
+ const url = store.presignGet({ userId: 'u1' }, { expiresIn: 60 });
54
+ const answer = await fetch(url);
55
+ answer.status; // 200 — it really reads the object
56
+ ```
57
+
58
+ ## A presigned PUT constrains the key and the deadline, and nothing else
59
+
60
+ Not the size. Not the content type.
61
+
62
+ ```ts
63
+ const url = store.presignPut({ userId: 'u1' }, { expiresIn: 60 });
64
+
65
+ await fetch(url, {
66
+ method: 'PUT',
67
+ body: Bun.file('archive.zip'),
68
+ headers: { 'content-type': 'application/zip' },
69
+ });
70
+ // 200 — stored, in a bucket whose definition says image/png only
71
+ ```
72
+
73
+ That is why `presignPut` takes no `type`: it would read as a guarantee it
74
+ cannot make. Measured on bun 1.4.2, `presign`'s `type` only adds
75
+ `response-content-type` — S3's override for what a *download* is labelled —
76
+ and `X-Amz-SignedHeaders` stays `host`, so the uploader's `Content-Type` is
77
+ never signed.
78
+
79
+ The bucket's `contentType` and `maxSize` are [`put`'s guards](writes.md),
80
+ and whoever holds a signed URL is not going through `put`. Where it matters:
81
+
82
+ - check with `stat` after the upload and delete what does not belong, or
83
+ - enforce it in the service's own bucket policy.
84
+
85
+ ```ts
86
+ const found = await store.stat({ userId: 'u1' });
87
+ if (!found || !found.type.startsWith('image/')) {
88
+ await store.delete({ userId: 'u1' });
89
+ }
90
+ ```
91
+
92
+ ## A URL is signed for the bound bucket, whatever the options say
93
+
94
+ `PresignOptions` carries no `bucket`, `endpoint`, `region` or credential, and
95
+ the run time signs with only the two keys above — anything else is dropped.
96
+
97
+ ```ts
98
+ import type { PresignOptions } from '@nxgt/s3';
99
+
100
+ // A bag off a request body never met the types.
101
+ const bag = { expiresIn: 60, bucket: 'somewhere-else' } as PresignOptions;
102
+
103
+ store.presignGet({ userId: 'u1' }, bag); // …/avatars/u1.png, on this endpoint
104
+ ```
105
+
106
+ Measured before that filter existed, such a bag redirected the URL: a
107
+ `bucket` key signed it for another bucket, and a credential key signed it
108
+ against another endpoint entirely.
109
+
110
+ ## A real one: upload from the browser, read it back
111
+
112
+ ```ts
113
+ import { Hono } from 'hono';
114
+ import { bindBucket, defineBucket } from '@nxgt/s3';
115
+
116
+ const avatars = defineBucket({
117
+ bucket: 'avatars',
118
+ key: (p: { userId: string }) => `${p.userId}.png`,
119
+ contentType: ['image/png', 'image/jpeg'],
120
+ maxSize: 2 * 1024 * 1024,
121
+ });
122
+
123
+ const store = bindBucket(avatars);
124
+ const app = new Hono();
125
+
126
+ // The browser PUTs the file straight to the service: no body through here.
127
+ app.post('/users/:id/avatar/upload-url', (c) => {
128
+ const userId = c.req.param('id');
129
+ return c.json({
130
+ url: store.presignPut({ userId }, { expiresIn: 120 }),
131
+ key: store.keyFor({ userId }),
132
+ });
133
+ });
134
+
135
+ // …and the browser is told, later, where to read it.
136
+ app.get('/users/:id/avatar', async (c) => {
137
+ const userId = c.req.param('id');
138
+ if (!(await store.exists({ userId }))) {
139
+ return c.json({ error: 'No avatar' }, 404);
140
+ }
141
+ return c.redirect(store.presignGet({ userId }, { expiresIn: 300 }));
142
+ });
143
+ ```
144
+
145
+ The upload route authorises the *user*; the URL it hands back authorises
146
+ nothing else. Keep `expiresIn` close to how long an upload should take, and
147
+ verify the object afterwards if the bucket's own policy does not.
148
+
149
+ ## Next
150
+
151
+ - [Writing](writes.md) — the guards a presigned PUT bypasses.
152
+ - [Reading](reads.md) — `stat` and `exists`, for checking what was uploaded.
153
+ - [Troubleshooting](../troubleshooting.md) — a URL that 403s or expired.
@@ -0,0 +1,186 @@
1
+ # Reading
2
+
3
+ Getting an object back — its bytes, its text, whether it is there, what the
4
+ service knows about it — and walking a bucket page by page.
5
+
6
+ ## The smallest thing that works
7
+
8
+ ```ts
9
+ import { bindBucket, defineBucket } from '@nxgt/s3';
10
+
11
+ const reports = defineBucket({
12
+ bucket: 'reports',
13
+ key: (id: string) => `${id}.csv`,
14
+ contentType: 'text/csv',
15
+ });
16
+
17
+ const store = bindBucket(reports);
18
+
19
+ const csv = await store.text('q1'); // string, or undefined
20
+ if (csv === undefined) throw new Error('no such report'); // nothing stored
21
+ ```
22
+
23
+ Describing and binding a bucket is [Buckets](buckets.md).
24
+
25
+ ## The signatures
26
+
27
+ ```ts
28
+ import type { S3File, S3Stats } from 'bun';
29
+
30
+ bytes(params: P): Promise<Uint8Array | undefined>;
31
+ text(params: P): Promise<string | undefined>;
32
+ stat(params: P): Promise<S3Stats | undefined>;
33
+ exists(params: P): Promise<boolean>;
34
+ file(params: P): S3File;
35
+
36
+ list(options?: {
37
+ prefix?: string;
38
+ limit?: number;
39
+ cursor?: string | null;
40
+ }): Promise<ObjectPage>;
41
+
42
+ interface ObjectPage {
43
+ items: StoredObject[];
44
+ nextCursor: string | null;
45
+ }
46
+
47
+ interface StoredObject {
48
+ key: string;
49
+ size: number | undefined;
50
+ lastModified: Date | undefined;
51
+ eTag: string | undefined;
52
+ }
53
+ ```
54
+
55
+ ## `undefined` means "no such object", and nothing else
56
+
57
+ ```ts
58
+ await store.bytes('nobody'); // undefined
59
+ await store.text('nobody'); // undefined
60
+ await store.stat('nobody'); // undefined
61
+ await store.exists('nobody'); // false
62
+ ```
63
+
64
+ Each read goes straight at the object and turns S3's own `NoSuchKey` into
65
+ `undefined`: one round trip, and no window in which an object deleted between
66
+ a check and a read turns a promised `undefined` into a throw. **Every other
67
+ failure comes back as the error it is** — a wrong secret, a refused request,
68
+ a service that is down. A missing object and a missing permission must not
69
+ read the same way.
70
+
71
+ ```ts
72
+ const wrong = bindBucket(reports, { secretAccessKey: 'wrong-secret' });
73
+ await wrong.text('q1'); // throws Bun's own S3Error, not undefined
74
+ ```
75
+
76
+ ## What the service knows
77
+
78
+ ```ts
79
+ const found = await store.stat('q1');
80
+ if (found) {
81
+ found.size; // bytes
82
+ found.type; // 'text/csv'
83
+ found.lastModified;
84
+ found.etag; // note: lower case — that is Bun's field
85
+ }
86
+ ```
87
+
88
+ `stat` is Bun's own `S3Stats`, which this package does not re-export; import
89
+ it from `bun` where you need to name it. A listing's `StoredObject` says
90
+ `eTag`, because that is what S3 calls it there.
91
+
92
+ ## Big bodies, and everything else
93
+
94
+ ```ts
95
+ const stream = store.file('q1').stream();
96
+ const head = await store.file('q1').slice(0, 1024).text();
97
+ ```
98
+
99
+ `file(params)` is Bun's lazy `S3File`, keyed by the definition. Use it to
100
+ stream a body rather than hold it in memory, and to reach anything this
101
+ package does not wrap.
102
+
103
+ ## Listing a bucket
104
+
105
+ ```ts
106
+ let cursor: string | null = null;
107
+
108
+ do {
109
+ const page = await store.list({ prefix: 'q1/', limit: 100, cursor });
110
+ for (const object of page.items) console.log(object.key, object.size);
111
+ cursor = page.nextCursor;
112
+ } while (cursor !== null);
113
+ ```
114
+
115
+ | Option | Type | Default | Effect |
116
+ | --- | --- | --- | --- |
117
+ | `prefix` | `string` | the whole bucket | only keys that start with it |
118
+ | `limit` | `number` | the service's | the **maximum** number of objects in a page, not a promise |
119
+ | `cursor` | `string \| null` | `null` | the `nextCursor` of the previous page |
120
+
121
+ `ObjectPage` is the same shape as the `CursorPage` of `@nxgt/drizzle` and
122
+ `@nxgt/mongo`, so a caller pages the same way everywhere. S3's
123
+ `continuationToken` is what `nextCursor` carries, and it is `null` on the
124
+ last page.
125
+
126
+ Page **until `nextCursor` is `null`**, never until a page is short: a listing
127
+ is eventually consistent on some services, and a page may come back with
128
+ fewer items than `limit` while there is still more to read. A prefix with
129
+ nothing under it gives an empty page rather than nothing:
130
+
131
+ ```ts
132
+ await store.list({ prefix: 'nothing-here/' }); // { items: [], nextCursor: null }
133
+ ```
134
+
135
+ `list` is the bucket's, not an object's, so it takes no key parameters — and
136
+ it does not count: S3 does not say how many objects there are, so there is no
137
+ `total` and no page number.
138
+
139
+ ## A real one: a download route
140
+
141
+ ```ts
142
+ import { Hono } from 'hono';
143
+ import { bindBucket, defineBucket } from '@nxgt/s3';
144
+
145
+ const reports = defineBucket({
146
+ bucket: 'reports',
147
+ key: (id: string) => `${id}.csv`,
148
+ contentType: 'text/csv',
149
+ });
150
+
151
+ const store = bindBucket(reports);
152
+ const app = new Hono();
153
+
154
+ app.get('/reports/:id', async (c) => {
155
+ const id = c.req.param('id');
156
+ const found = await store.stat(id);
157
+ if (!found) return c.json({ error: 'No such report' }, 404);
158
+
159
+ return new Response(store.file(id).stream(), {
160
+ headers: {
161
+ 'content-type': found.type,
162
+ 'content-length': String(found.size),
163
+ 'content-disposition': `attachment; filename="${id}.csv"`,
164
+ },
165
+ });
166
+ });
167
+
168
+ app.get('/reports', async (c) => {
169
+ const page = await store.list({
170
+ limit: 50,
171
+ cursor: c.req.query('cursor') ?? null,
172
+ });
173
+ return c.json(page);
174
+ });
175
+ ```
176
+
177
+ `stat` is a HEAD, so this costs one round trip before the body is streamed —
178
+ and it is what turns a missing object into a 404 rather than a broken stream.
179
+
180
+ ## Next
181
+
182
+ - [Writing](writes.md) — `put`, its options and the guards.
183
+ - [Presigned URLs](presigned-urls.md) — handing the download to the client
184
+ instead of streaming it through your server.
185
+ - [Troubleshooting](../troubleshooting.md) — a read that threw instead of
186
+ answering `undefined`.
@@ -0,0 +1,260 @@
1
+ # Writing
2
+
3
+ Storing an object, saying what it is and how it should be served back, and
4
+ getting a refusal **before** anything goes over the wire.
5
+
6
+ ## The smallest thing that works
7
+
8
+ ```ts
9
+ import { bindBucket, defineBucket } from '@nxgt/s3';
10
+
11
+ const avatars = defineBucket({
12
+ bucket: 'avatars',
13
+ key: (p: { userId: string }) => `${p.userId}.png`,
14
+ contentType: ['image/png', 'image/jpeg'],
15
+ maxSize: 2 * 1024 * 1024,
16
+ });
17
+
18
+ const store = bindBucket(avatars);
19
+
20
+ await store.put({ userId: 'u1' }, png, { type: 'image/png' });
21
+ await store.delete({ userId: 'u1' });
22
+ ```
23
+
24
+ Describing the bucket is [Buckets](buckets.md). `put` resolves when the
25
+ object is stored, and gives back nothing: S3 has nothing to say beyond that.
26
+
27
+ ## The signatures
28
+
29
+ ```ts
30
+ import type { S3Client, S3Options } from 'bun';
31
+
32
+ /** A string, bytes, a Blob, a Bun.file, a stream, a Response… */
33
+ type PutBody = Parameters<S3Client['write']>[1];
34
+
35
+ type PutOptions = Pick<
36
+ S3Options,
37
+ 'type' | 'acl' | 'storageClass' | 'contentDisposition' | 'contentEncoding'
38
+ >;
39
+
40
+ put(params: P, body: PutBody, options?: PutOptions): Promise<void>;
41
+ delete(params: P): Promise<void>;
42
+ ```
43
+
44
+ ## What a body may be
45
+
46
+ Anything Bun's own `write` takes:
47
+
48
+ ```ts
49
+ await store.put({ userId: 'u1' }, 'a,b\n1,2\n', { type: 'text/csv' });
50
+ await store.put({ userId: 'u1' }, new Uint8Array([1, 2, 3]), { type: 'image/png' });
51
+ await store.put({ userId: 'u1' }, Bun.file('ada.png')); // the type comes from the file
52
+ await store.put({ userId: 'u1' }, await fetch(url)); // only without `maxSize`
53
+ ```
54
+
55
+ A `Blob` — `Bun.file` included — carries its own content type, so a write
56
+ that names none is still checked against one. A string is measured in
57
+ **bytes**, not characters.
58
+
59
+ ## What a write may say about the object
60
+
61
+ Every option here describes **the object being stored**. A write says nothing
62
+ about where it goes or how it gets there: the bucket, the endpoint, the
63
+ region and the credentials belong to the bound bucket.
64
+
65
+ | Option | Type | Default | Effect |
66
+ | --- | --- | --- | --- |
67
+ | `type` | `string` | the body's own, for a `Blob` | the content type. It is the one the guard checks **and** the one the service receives — they can never be two different answers |
68
+ | `acl` | `'private' \| 'public-read' \| 'public-read-write' \| 'aws-exec-read' \| 'authenticated-read' \| 'bucket-owner-read' \| 'bucket-owner-full-control' \| 'log-delivery-write'` | the client's, else the service's | who may read the object, where the service implements ACLs |
69
+ | `storageClass` | `'STANDARD' \| 'STANDARD_IA' \| 'ONEZONE_IA' \| 'INTELLIGENT_TIERING' \| 'GLACIER' \| 'GLACIER_IR' \| 'DEEP_ARCHIVE' \| 'REDUCED_REDUNDANCY' \| 'EXPRESS_ONEZONE' \| 'OUTPOSTS' \| 'SNOW'` | the client's, else `STANDARD` | what it costs to keep it; comes back as `x-amz-storage-class` |
70
+ | `contentDisposition` | `string` | none | how a reader should present it; comes back on a `GET` |
71
+ | `contentEncoding` | `string` | none | how the body is encoded; comes back on a `GET` |
72
+
73
+ ```ts
74
+ await store.put({ userId: 'u1' }, png, {
75
+ type: 'image/png',
76
+ contentDisposition: 'attachment; filename="ada.png"',
77
+ contentEncoding: 'identity',
78
+ storageClass: 'STANDARD_IA',
79
+ acl: 'public-read',
80
+ });
81
+ ```
82
+
83
+ Measured against a real service: `contentDisposition` and `contentEncoding`
84
+ come back as the headers of the same name on a read, and `storageClass` as
85
+ `x-amz-storage-class`.
86
+
87
+ These are Bun's own names, picked out of its `S3Options`, so a Bun release
88
+ that changes one is a compile error here rather than a silent drift. Given
89
+ both here and to `bindBucket`, the one on the write wins: it is the last
90
+ thing handed to the client.
91
+
92
+ ### An option's *value* is Bun's to check, and Bun throws its own error
93
+
94
+ The guards below own the content type and the size, and raise `S3Error`. A
95
+ value outside one of Bun's unions is Bun's to refuse, and it refuses with a
96
+ plain `TypeError`:
97
+
98
+ ```ts
99
+ import type { PutOptions } from '@nxgt/s3';
100
+
101
+ const bad = { type: 'text/plain', storageClass: 'NOPE' } as unknown as PutOptions;
102
+
103
+ await store.put({ userId: 'u1' }, 'x', bad);
104
+ // TypeError: storageClass must be one of …
105
+ // `error instanceof S3Error` is false. Nothing was stored.
106
+ ```
107
+
108
+ `acl` behaves the same way. `contentDisposition` and `contentEncoding` are
109
+ plain strings to Bun and accept **anything** — nothing there is refused, by
110
+ this package or by Bun. Nothing is sent in either case; it is the class a
111
+ handler catches that differs. Validate a bag that arrives from a request body
112
+ before passing it on.
113
+
114
+ ### A write cannot change where it goes
115
+
116
+ `PutOptions` carries no `bucket`, `endpoint`, `region` or credential, and the
117
+ run time forwards only the five keys above — a key that is not in that list
118
+ is dropped, never sent.
119
+
120
+ ```ts
121
+ // A bag off a request body never met the types.
122
+ const smuggled = {
123
+ type: 'text/plain',
124
+ bucket: 'somewhere-else',
125
+ accessKeyId: 'someone-else',
126
+ } as PutOptions;
127
+
128
+ await store.put({ userId: 'u1' }, 'x', smuggled);
129
+ // Stored in `avatars`, with the credentials the bucket was bound with.
130
+ ```
131
+
132
+ Measured before that filter existed, spreading the caller's options straight
133
+ through let a `bucket` key store the object in another bucket — and report
134
+ success.
135
+
136
+ ### A `put` is one PUT
137
+
138
+ `partSize`, `queueSize` and `retry` are not `PutOptions`: they belong to
139
+ [`bindBucket`](buckets.md), where they are the client's own. Measured on bun
140
+ 1.4.2 against a 12 MiB body, a `put` with `partSize` set and one without come
141
+ back with the **same** ETag, and neither carries the `-<parts>` suffix a
142
+ multipart upload leaves. For a body that wants parts, use
143
+ `store.file(params).writer()`.
144
+
145
+ ## The guards
146
+
147
+ `contentType` and `maxSize` on the definition are checked in `put`, before
148
+ the request goes out. A refused body is never sent.
149
+
150
+ ```ts
151
+ import { S3Error } from '@nxgt/s3';
152
+
153
+ try {
154
+ await store.put({ userId: 'u1' }, pdf, { type: 'application/pdf' });
155
+ } catch (error) {
156
+ if (error instanceof S3Error) {
157
+ error.code; // 'WRONG_TYPE'
158
+ error.key; // 'u1.png' — the key, never the body
159
+ }
160
+ }
161
+ ```
162
+
163
+ | `S3ErrorCode` | When |
164
+ | --- | --- |
165
+ | `WRONG_TYPE` | the type is not one this bucket accepts — or the write named none and the body carries none, while the bucket names some |
166
+ | `TOO_LARGE` | the body is bigger than `maxSize`. The limit is inclusive: with `maxSize: 1024`, 1024 bytes passes and 1025 does not |
167
+ | `UNMEASURABLE` | `maxSize` is set and the body's size cannot be known before sending |
168
+
169
+ A content type is compared on its **essence**: parameters and case are
170
+ ignored, and nothing else is. A bucket that accepts `text/csv` accepts
171
+ `text/csv;charset=utf-8` and `TEXT/CSV`, because that is what real bodies
172
+ carry — measured, `Bun.file` labels `.txt` as `text/plain;charset=utf-8` and
173
+ `.csv` as the bare `text/csv`.
174
+
175
+ ```ts
176
+ const reports = defineBucket({
177
+ bucket: 'reports',
178
+ key: (id: string) => `${id}.csv`,
179
+ contentType: 'text/csv',
180
+ });
181
+ const csv = bindBucket(reports);
182
+
183
+ await csv.put('q1', Bun.file('q1.csv')); // stored
184
+ await csv.put('q1', Bun.file('note.txt')); // S3Error WRONG_TYPE, nothing sent
185
+ ```
186
+
187
+ `UNMEASURABLE` is the one that surprises: with `maxSize` set, a `Response`, a
188
+ `Request`, a stream or another `S3File` is **refused, not streamed** —
189
+ nothing can check a length it has not read, and an `S3File` reports its size
190
+ as `NaN` until the service has been asked. Read it into memory first, or
191
+ leave `maxSize` out and let the service refuse an oversized body.
192
+
193
+ ```ts
194
+ const answer = await fetch(url);
195
+ const body = new Uint8Array(await answer.arrayBuffer()); // now it has a length
196
+ await store.put({ userId: 'u1' }, body, { type: 'image/png' });
197
+ ```
198
+
199
+ The guards are `put`'s, not the bucket's: `store.file(params).writer()`,
200
+ `store.client` and anyone holding a [presigned PUT](presigned-urls.md) write
201
+ whatever they are given. Set the service's own policy too where it matters.
202
+
203
+ ## Deleting
204
+
205
+ ```ts
206
+ await store.delete({ userId: 'u1' });
207
+ ```
208
+
209
+ S3 does not say whether anything was there, and nor does this. Call `exists`
210
+ first when it matters — see [Reading](reads.md).
211
+
212
+ ## A real one: an upload route
213
+
214
+ ```ts
215
+ import { Hono } from 'hono';
216
+ import { bindBucket, defineBucket, S3Error } from '@nxgt/s3';
217
+
218
+ const avatars = defineBucket({
219
+ bucket: 'avatars',
220
+ key: (p: { userId: string }) => `${p.userId}.png`,
221
+ contentType: ['image/png', 'image/jpeg'],
222
+ maxSize: 2 * 1024 * 1024,
223
+ });
224
+
225
+ const store = bindBucket(avatars);
226
+ const app = new Hono();
227
+
228
+ app.put('/users/:id/avatar', async (c) => {
229
+ const userId = c.req.param('id');
230
+ const body = await c.req.blob(); // a Blob: it carries its own type
231
+
232
+ try {
233
+ await store.put({ userId }, body, {
234
+ contentDisposition: `inline; filename="${userId}.png"`,
235
+ });
236
+ } catch (error) {
237
+ if (error instanceof S3Error && error.code === 'WRONG_TYPE') {
238
+ return c.json({ error: 'Send a PNG or a JPEG' }, 415);
239
+ }
240
+ if (error instanceof S3Error && error.code === 'TOO_LARGE') {
241
+ return c.json({ error: 'That file is too big' }, 413);
242
+ }
243
+ throw error;
244
+ }
245
+
246
+ return c.json({ key: store.keyFor({ userId }) }, 201);
247
+ });
248
+ ```
249
+
250
+ Every `S3Error` is thrown before the request goes out, so a 413 costs no
251
+ bandwidth. Bun names **its own** S3 failures `S3Error` too, with S3's codes:
252
+ discriminate with `instanceof S3Error` on this package's class, never with
253
+ `error.name`.
254
+
255
+ ## Next
256
+
257
+ - [Reading](reads.md) — reading back what was written.
258
+ - [Presigned URLs](presigned-urls.md) — letting a browser upload directly,
259
+ and what that does *not* constrain.
260
+ - [Troubleshooting](../troubleshooting.md) — each error with its fix.
@@ -0,0 +1,58 @@
1
+ # Roadmap
2
+
3
+ Where `@nxgt/s3` is going. A direction, not a commitment: the version an item
4
+ shipped in is the only number on this page.
5
+
6
+ ## Now
7
+
8
+ _Nothing in progress._
9
+
10
+ ## Next
11
+
12
+ _Nothing queued._
13
+
14
+ ## Later
15
+
16
+ - **Copy and move** — once Bun's own `S3Client` has them. Until then
17
+ `store.client` is where they live.
18
+
19
+ ## Not planned
20
+
21
+ - **Running on Node** — the client is Bun's own `S3Client`, which is why this
22
+ package installs no AWS SDK at all. There is nothing to swap for Node, and a
23
+ build for it is not coming: it needs Bun 1.4 or later.
24
+ - **Multipart upload** — a body goes out in one `put`. For anything bigger,
25
+ `store.file(params)` hands back Bun's own file handle and its `writer()`.
26
+ - **Bucket administration** — creating, deleting or configuring a bucket is
27
+ not this package's, and Bun's client has no `createBucket` either.
28
+ - **Retries or a cache of its own** — Bun's client already retries (three
29
+ attempts by default, and `retry` is passed through on `bindBucket`), and a
30
+ failed request comes back with S3's own error, as `S3Error`.
31
+ - **Upload tuning on a `put`** — a `put` is a single PUT: measured against a
32
+ 12 MiB body, `partSize` changes nothing, right down to the ETag carrying no
33
+ `-<parts>` suffix. `PutOptions` describes the object, not the transfer.
34
+ - **An option that says where a write goes** — `bucket`, `endpoint`, `region`
35
+ and the credentials belong to the bound bucket, and are refused by the types
36
+ and again at run time. Measured: passing them through let a bag store the
37
+ object in a different bucket and report success, and redirected a signed URL
38
+ the same way.
39
+
40
+ ## Shipped
41
+
42
+ - **Documentation that travels with the package** — a guide page for the
43
+ bucket definition, writes, reads and presigned URLs, a troubleshooting page
44
+ whose headings are the exact error text, and this roadmap, installed in
45
+ `docs/` rather than left on GitHub — 0.2.1.
46
+ - **What a write may say about the object** — `contentDisposition`,
47
+ `contentEncoding`, `acl` and `storageClass` on `put`, with Bun's own names;
48
+ an option naming another bucket, endpoint, region or credential is refused,
49
+ on `put` and on both `presign` calls — 0.2.0.
50
+ - **First release** — `defineBucket` naming the bucket, the key-building
51
+ function, the content types it accepts and the biggest body it takes, and
52
+ `bindBucket` giving `put`, `bytes`, `text`, `exists`, `stat`, `delete`, a
53
+ cursor `list` and `presignGet` / `presignPut`; the content type and the size
54
+ are checked before the request goes out, and its one error is `S3Error` —
55
+ 0.1.0.
56
+
57
+ Everything released is in [`CHANGELOG.md`](https://github.com/softistx/nxgt-data/blob/develop/packages/s3/CHANGELOG.md) — it is not in
58
+ the published package, only in the repository.
@@ -0,0 +1,276 @@
1
+ # Troubleshooting
2
+
3
+ This package throws one error of its own, `S3Error`, with a `code` of
4
+ `WRONG_TYPE`, `TOO_LARGE` or `UNMEASURABLE`, and the object `key` it was
5
+ about — never the body. Every one of them is raised **before** anything is
6
+ sent. The service's own failures, and the checks Bun makes on an option's
7
+ value, come back as Bun raises them; the entries below say which is which.
8
+ The Bun messages were measured on Bun 1.4.2.
9
+
10
+ - **Install and import**
11
+ - [`Cannot find package 'bun'`](#cannot-find-package-bun)
12
+ - [`Cannot find module 'bun' or its corresponding type declarations.`](#cannot-find-module-bun-or-its-corresponding-type-declarations)
13
+ - **Configuration**
14
+ - [`defineBucket: a bucket definition needs a bucket`](#definebucket-a-bucket-definition-needs-a-bucket)
15
+ - [`defineBucket: "avatars" has a maxSize of 0; it is a number of bytes, and must be above zero`](#definebucket-avatars-has-a-maxsize-of-0-it-is-a-number-of-bytes-and-must-be-above-zero)
16
+ - [`defineBucket: "avatars" accepts an empty list of content types, so nothing could ever be written. …`](#definebucket-avatars-accepts-an-empty-list-of-content-types-so-nothing-could-ever-be-written-)
17
+ - **Writes refused before they are sent**
18
+ - [`"avatars" accepts image/png, image/jpeg, not application/pdf`](#avatars-accepts-imagepng-imagejpeg-not-applicationpdf)
19
+ - [``"avatars" accepts image/png, image/jpeg, and this write names no content type. Pass `type` ``](#avatars-accepts-imagepng-imagejpeg-and-this-write-names-no-content-type-pass-type-)
20
+ - [`"avatars" accepts 2097152 bytes at most, and this body is 5242880`](#avatars-accepts-2097152-bytes-at-most-and-this-body-is-5242880)
21
+ - [`"avatars" has a maxSize, and this body's size cannot be known before sending it. …`](#avatars-has-a-maxsize-and-this-bodys-size-cannot-be-known-before-sending-it-)
22
+ - [`storageClass must be one of "STANDARD", "STANDARD_IA", "INTELLIGENT_TIERING", …`](#storageclass-must-be-one-of-standard-standard_ia-intelligent_tiering-)
23
+ - [`acl must be one of "private", "public-read", "public-read-write", …`](#acl-must-be-one-of-private-public-read-public-read-write-)
24
+ - **The service**
25
+ - [`Missing S3 credentials. 'accessKeyId', 'secretAccessKey', 'bucket', and 'endpoint' are required`](#missing-s3-credentials-accesskeyid-secretaccesskey-bucket-and-endpoint-are-required)
26
+ - [`The AWS Access Key Id you provided does not exist in our records.`](#the-aws-access-key-id-you-provided-does-not-exist-in-our-records)
27
+ - [A presigned upload stored a body the bucket would have refused](#a-presigned-upload-stored-a-body-the-bucket-would-have-refused)
28
+ - [A listing came back short with a cursor still set](#a-listing-came-back-short-with-a-cursor-still-set)
29
+
30
+ ## Install and import
31
+
32
+ ### `Cannot find package 'bun'`
33
+
34
+ **When:** importing `@nxgt/s3` under Node — the full line names the file it
35
+ was imported from.
36
+ **Why:** the client is Bun's own `S3Client`, which is why there is no AWS SDK
37
+ to install and why this package does not run on Node. Measured on Node 22.
38
+ **Fix:**
39
+
40
+ ```sh
41
+ bun run ./src/index.ts # Bun 1.4 or later
42
+ ```
43
+
44
+ ### `Cannot find module 'bun' or its corresponding type declarations.`
45
+
46
+ **When:** typechecking, on the first file that imports `@nxgt/s3`.
47
+ **Why:** the shipped declarations import `S3Client`, `S3File` and `S3Options`
48
+ from `bun`, so your project needs Bun's types. They are not a dependency of
49
+ this package.
50
+ **Fix:**
51
+
52
+ ```sh
53
+ bun add -d @types/bun
54
+ ```
55
+
56
+ ## Configuration
57
+
58
+ ### `defineBucket: a bucket definition needs a bucket`
59
+
60
+ **When:** at `defineBucket`, with an empty `bucket`.
61
+ **Why:** the bucket name is what every key is written under; an empty one
62
+ cannot be meant.
63
+ **Fix:**
64
+
65
+ ```ts
66
+ export const avatars = defineBucket({
67
+ bucket: 'avatars',
68
+ key: (p: { userId: string }) => `${p.userId}.png`,
69
+ });
70
+ ```
71
+
72
+ ### `defineBucket: "avatars" has a maxSize of 0; it is a number of bytes, and must be above zero`
73
+
74
+ **When:** at `defineBucket`.
75
+ **Why:** `maxSize` is a number of **bytes**, and it is inclusive: 1024 passes
76
+ and 1025 does not. A string body is measured in bytes, not in characters.
77
+ **Fix:**
78
+
79
+ ```ts
80
+ defineBucket({ bucket: 'avatars', key, maxSize: 2 * 1024 * 1024 });
81
+ ```
82
+
83
+ ### `defineBucket: "avatars" accepts an empty list of content types, so nothing could ever be written. …`
84
+
85
+ **When:** at `defineBucket`, with `contentType: []`.
86
+ **Why:** an empty list refuses every write. The message ends with the way out,
87
+ ``Leave `contentType` out to accept anything``: leaving the option out is what
88
+ accepts anything, and `[]` is never what was meant.
89
+ **Fix:**
90
+
91
+ ```ts
92
+ defineBucket({ bucket: 'avatars', key, contentType: ['image/png', 'image/jpeg'] });
93
+ ```
94
+
95
+ ## Writes refused before they are sent
96
+
97
+ ### `"avatars" accepts image/png, image/jpeg, not application/pdf`
98
+
99
+ **When:** `put` or `presignGet`, with a `type` the definition does not list.
100
+ **Why:** an `S3Error` with `code: 'WRONG_TYPE'`. Nothing was sent. The type
101
+ is compared on its **essence**: `text/csv` accepts `text/csv;charset=utf-8`
102
+ and `TEXT/CSV`, because that is what real bodies carry — parameters and case
103
+ are ignored, nothing else is.
104
+ **Fix:**
105
+
106
+ ```ts
107
+ import { S3Error } from '@nxgt/s3';
108
+
109
+ try {
110
+ await avatars.put({ userId }, body, { type: file.type });
111
+ } catch (error) {
112
+ if (error instanceof S3Error && error.code === 'WRONG_TYPE') return badRequest();
113
+ throw error;
114
+ }
115
+ ```
116
+
117
+ ### ``"avatars" accepts image/png, image/jpeg, and this write names no content type. Pass `type` ``
118
+
119
+ **When:** `put` on a bucket with `contentType`, for a body that carries no
120
+ type of its own — a string, a typed array, a stream.
121
+ **Why:** the guard cannot accept what it cannot read, so an unnamed type is
122
+ refused rather than guessed. `code: 'WRONG_TYPE'`.
123
+ **Fix:**
124
+
125
+ ```ts
126
+ await avatars.put({ userId }, bytes, { type: 'image/png' });
127
+ ```
128
+
129
+ `Bun.file(path)` carries its own type, and does not need the option.
130
+
131
+ ### `"avatars" accepts 2097152 bytes at most, and this body is 5242880`
132
+
133
+ **When:** `put`, for a body whose size is known and above `maxSize`.
134
+ **Why:** an `S3Error` with `code: 'TOO_LARGE'`, raised before the request goes
135
+ out.
136
+ **Fix:**
137
+
138
+ ```ts
139
+ if (error instanceof S3Error && error.code === 'TOO_LARGE') return payloadTooLarge();
140
+ ```
141
+
142
+ ### `"avatars" has a maxSize, and this body's size cannot be known before sending it. …`
143
+
144
+ **When:** `put` with `maxSize` set, for a `Response`, a `Request`, a stream or
145
+ another `S3File`.
146
+ **Why:** nothing can check a length it has not read — an `S3File` reports its
147
+ size as `NaN` until the service has been asked — so the write is refused
148
+ rather than streamed unchecked. `code: 'UNMEASURABLE'`. The message ends with
149
+ the two ways out, ``Read it into memory first, or drop `maxSize` and let the
150
+ service refuse it``.
151
+ **Fix:**
152
+
153
+ ```ts
154
+ await avatars.put({ userId }, await response.bytes()); // read it in first
155
+ ```
156
+
157
+ Or drop `maxSize` from the definition and let the service refuse an oversized
158
+ body.
159
+
160
+ ### `storageClass must be one of "STANDARD", "STANDARD_IA", "INTELLIGENT_TIERING", …`
161
+
162
+ **When:** `put` or `presignPut` with a `storageClass` Bun does not know.
163
+ **Why:** an option's **value** is Bun's to check, and it throws **Bun's own
164
+ `TypeError`** — not an `S3Error`. `instanceof S3Error` is false, and
165
+ `error.code` is not one of this package's. Nothing is sent either way; it is
166
+ the class a handler catches that differs.
167
+ **Fix:**
168
+
169
+ ```ts
170
+ import type { PutOptions } from '@nxgt/s3';
171
+
172
+ // the classes this application allows, proved against Bun's own list
173
+ const CLASSES = ['STANDARD', 'STANDARD_IA'] as const satisfies readonly NonNullable<
174
+ PutOptions['storageClass']
175
+ >[];
176
+
177
+ function storageClassOf(value: string | undefined): PutOptions['storageClass'] {
178
+ return CLASSES.find((known) => known === value); // undefined: the bucket's default
179
+ }
180
+
181
+ await avatars.put({ userId }, bytes, { storageClass: storageClassOf(body.storageClass) });
182
+ ```
183
+
184
+ Catch `TypeError` beside `S3Error` where such a value can reach a call.
185
+
186
+ ### `acl must be one of "private", "public-read", "public-read-write", …`
187
+
188
+ **When:** `put`, `presignGet` or `presignPut` with an `acl` Bun does not
189
+ know.
190
+ **Why:** the same as `storageClass`: Bun's own `TypeError`, before anything is
191
+ sent. `contentDisposition` and `contentEncoding`, by contrast, are plain
192
+ strings to Bun and accept anything.
193
+ **Fix:**
194
+
195
+ ```ts
196
+ await avatars.put({ userId }, bytes, { acl: 'public-read' });
197
+ ```
198
+
199
+ ## The service
200
+
201
+ ### `Missing S3 credentials. 'accessKeyId', 'secretAccessKey', 'bucket', and 'endpoint' are required`
202
+
203
+ **When:** the first call, when neither `bindBucket`'s options nor the
204
+ environment gave the client credentials. Bun's error, `code:
205
+ 'ERR_S3_MISSING_CREDENTIALS'`.
206
+ **Why:** `bindBucket` creates the `S3Client` for you and passes your options
207
+ through; with none, Bun falls back to the environment, and to AWS's endpoint.
208
+ **Fix:**
209
+
210
+ ```ts
211
+ export const avatars = bindBucket(avatarsDefinition, {
212
+ endpoint: process.env.S3_ENDPOINT,
213
+ accessKeyId: process.env.S3_KEY,
214
+ secretAccessKey: process.env.S3_SECRET,
215
+ virtualHostedStyle: false, // for a service that is not AWS
216
+ });
217
+ ```
218
+
219
+ ### `The AWS Access Key Id you provided does not exist in our records.`
220
+
221
+ **When:** the first call, with credentials the service refuses — or with no
222
+ `endpoint`, which sends the request to AWS whatever your service is.
223
+ **Why:** it is the **service's** answer, raised by Bun as an error whose
224
+ `name` is `S3Error` and whose `code` is the S3 code (`InvalidAccessKeyId`,
225
+ `SignatureDoesNotMatch`, `NoSuchBucket`, `AccessDenied`). It is **not** this
226
+ package's `S3Error` class: `instanceof S3Error` is false for it.
227
+ **Fix:**
228
+
229
+ ```ts
230
+ try {
231
+ await avatars.put({ userId }, bytes);
232
+ } catch (error) {
233
+ if (error instanceof S3Error) return badRequest(error.code); // ours: the guards
234
+ throw error; // the service's, or Bun's: log the code it carries
235
+ }
236
+ ```
237
+
238
+ `bytes`, `text` and `stat` are the exception: they turn S3's own `NoSuchKey`
239
+ into `undefined`, so `undefined` means "no such object" and nothing else.
240
+ Every other failure comes back as the error it is.
241
+
242
+ ### A presigned upload stored a body the bucket would have refused
243
+
244
+ **When:** after handing out a `presignPut` URL. No error anywhere.
245
+ **Why:** a presigned PUT constrains the key and the deadline, and nothing
246
+ else. Measured on Bun 1.4: `X-Amz-SignedHeaders` stays `host`, so the
247
+ uploader's `Content-Type` is never signed and the size is never checked —
248
+ which is why `presignPut` takes no `type` at all. The guards are `put`'s;
249
+ `file(params).writer()` and `client` are Bun's own and write whatever they
250
+ are given.
251
+ **Fix:**
252
+
253
+ ```ts
254
+ const url = avatars.presignPut({ userId }, { expiresIn: 300 });
255
+ // after the upload, check what actually landed
256
+ const stat = await avatars.stat({ userId });
257
+ if (!stat || stat.size > maxSize) await avatars.delete({ userId });
258
+ ```
259
+
260
+ Set the service's own bucket policy too where it matters.
261
+
262
+ ### A listing came back short with a cursor still set
263
+
264
+ **When:** `list`, on an eventually consistent service.
265
+ **Why:** `limit` is a maximum, not a promise.
266
+ **Fix:**
267
+
268
+ ```ts
269
+ let cursor: string | null = null;
270
+ do {
271
+ const page = await avatars.list({ prefix, limit: 100, cursor });
272
+ cursor = page.nextCursor;
273
+ } while (cursor !== null);
274
+ ```
275
+
276
+ Page until `nextCursor` is `null`, never until a page is short.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/s3",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "S3 on Bun's own client: buckets described once, keys built by a typed function, uploads refused before they are sent, and presigned URLs from the same definition",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -8,6 +8,7 @@
8
8
  "types": "./dist/index.d.ts",
9
9
  "files": [
10
10
  "dist",
11
+ "docs",
11
12
  "README.md",
12
13
  "package.json",
13
14
  "LICENSE"