@nxgt/s3 0.2.0 → 0.3.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 +28 -11
- package/dist/bucket/guards.d.ts +6 -0
- package/dist/bucket/guards.d.ts.map +1 -1
- package/dist/bucket/operations/presign.d.ts.map +1 -1
- package/dist/bucket/operations/writes.d.ts.map +1 -1
- package/dist/errors/s3-error.d.ts +6 -1
- package/dist/errors/s3-error.d.ts.map +1 -1
- package/dist/index.js +108 -52
- package/dist/index.js.map +7 -7
- package/docs/README.md +16 -0
- package/docs/guide/buckets.md +170 -0
- package/docs/guide/presigned-urls.md +180 -0
- package/docs/guide/reads.md +186 -0
- package/docs/guide/writes.md +273 -0
- package/docs/roadmap.md +64 -0
- package/docs/troubleshooting.md +328 -0
- package/package.json +2 -1
|
@@ -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,180 @@
|
|
|
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. Above 604 800 (seven days, S3's own limit), at or below zero, or anything that is not a finite number is an `S3Error` with `code: 'WRONG_OPTION'` |
|
|
47
|
+
| `acl` | `'private' \| 'public-read' \| …` | none | the ACL the URL is signed for, where the service honours it |
|
|
48
|
+
|
|
49
|
+
`acl` goes through the same allowlist a `put` is held to, so a value the
|
|
50
|
+
service does not accept is an `S3Error` with `code: 'WRONG_OPTION'` and no
|
|
51
|
+
URL is signed:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { S3Error } from '@nxgt/s3';
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
store.presignPut({ userId: 'u1' }, { acl: 'everyone' as never });
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof S3Error && error.code === 'WRONG_OPTION') {
|
|
60
|
+
error.key; // 'u1.png' — the object it was about
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`expiresIn` goes through the same check. Measured on bun 1.4.2, the client
|
|
66
|
+
refuses `0` and below itself, and **signs** an `expiresIn` of `1e12`
|
|
67
|
+
happily — a URL S3 then rejects at use time, which is the one thing this
|
|
68
|
+
package exists not to do. Both ends are refused here now, before anything is
|
|
69
|
+
signed.
|
|
70
|
+
|
|
71
|
+
Before 0.3.0 the values went straight to the client: the same mistake was two
|
|
72
|
+
different classes depending on whether it reached a `put` or a `presign`.
|
|
73
|
+
There is no `storageClass` here — signing a URL stores nothing, so there is
|
|
74
|
+
no class to name.
|
|
75
|
+
|
|
76
|
+
A day is a long time for a URL that anyone can forward. Sign for the time the
|
|
77
|
+
page actually needs:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const url = store.presignGet({ userId: 'u1' }, { expiresIn: 60 });
|
|
81
|
+
const answer = await fetch(url);
|
|
82
|
+
answer.status; // 200 — it really reads the object
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## A presigned PUT constrains the key and the deadline, and nothing else
|
|
86
|
+
|
|
87
|
+
Not the size. Not the content type.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const url = store.presignPut({ userId: 'u1' }, { expiresIn: 60 });
|
|
91
|
+
|
|
92
|
+
await fetch(url, {
|
|
93
|
+
method: 'PUT',
|
|
94
|
+
body: Bun.file('archive.zip'),
|
|
95
|
+
headers: { 'content-type': 'application/zip' },
|
|
96
|
+
});
|
|
97
|
+
// 200 — stored, in a bucket whose definition says image/png only
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
That is why `presignPut` takes no `type`: it would read as a guarantee it
|
|
101
|
+
cannot make. Measured on bun 1.4.2, `presign`'s `type` only adds
|
|
102
|
+
`response-content-type` — S3's override for what a *download* is labelled —
|
|
103
|
+
and `X-Amz-SignedHeaders` stays `host`, so the uploader's `Content-Type` is
|
|
104
|
+
never signed.
|
|
105
|
+
|
|
106
|
+
The bucket's `contentType` and `maxSize` are [`put`'s guards](writes.md),
|
|
107
|
+
and whoever holds a signed URL is not going through `put`. Where it matters:
|
|
108
|
+
|
|
109
|
+
- check with `stat` after the upload and delete what does not belong, or
|
|
110
|
+
- enforce it in the service's own bucket policy.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const found = await store.stat({ userId: 'u1' });
|
|
114
|
+
if (!found || !found.type.startsWith('image/')) {
|
|
115
|
+
await store.delete({ userId: 'u1' });
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## A URL is signed for the bound bucket, whatever the options say
|
|
120
|
+
|
|
121
|
+
`PresignOptions` carries no `bucket`, `endpoint`, `region` or credential, and
|
|
122
|
+
the run time signs with only the two keys above — anything else is dropped.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import type { PresignOptions } from '@nxgt/s3';
|
|
126
|
+
|
|
127
|
+
// A bag off a request body never met the types.
|
|
128
|
+
const bag = { expiresIn: 60, bucket: 'somewhere-else' } as PresignOptions;
|
|
129
|
+
|
|
130
|
+
store.presignGet({ userId: 'u1' }, bag); // …/avatars/u1.png, on this endpoint
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Measured before that filter existed, such a bag redirected the URL: a
|
|
134
|
+
`bucket` key signed it for another bucket, and a credential key signed it
|
|
135
|
+
against another endpoint entirely.
|
|
136
|
+
|
|
137
|
+
## A real one: upload from the browser, read it back
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { Hono } from 'hono';
|
|
141
|
+
import { bindBucket, defineBucket } from '@nxgt/s3';
|
|
142
|
+
|
|
143
|
+
const avatars = defineBucket({
|
|
144
|
+
bucket: 'avatars',
|
|
145
|
+
key: (p: { userId: string }) => `${p.userId}.png`,
|
|
146
|
+
contentType: ['image/png', 'image/jpeg'],
|
|
147
|
+
maxSize: 2 * 1024 * 1024,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const store = bindBucket(avatars);
|
|
151
|
+
const app = new Hono();
|
|
152
|
+
|
|
153
|
+
// The browser PUTs the file straight to the service: no body through here.
|
|
154
|
+
app.post('/users/:id/avatar/upload-url', (c) => {
|
|
155
|
+
const userId = c.req.param('id');
|
|
156
|
+
return c.json({
|
|
157
|
+
url: store.presignPut({ userId }, { expiresIn: 120 }),
|
|
158
|
+
key: store.keyFor({ userId }),
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// …and the browser is told, later, where to read it.
|
|
163
|
+
app.get('/users/:id/avatar', async (c) => {
|
|
164
|
+
const userId = c.req.param('id');
|
|
165
|
+
if (!(await store.exists({ userId }))) {
|
|
166
|
+
return c.json({ error: 'No avatar' }, 404);
|
|
167
|
+
}
|
|
168
|
+
return c.redirect(store.presignGet({ userId }, { expiresIn: 300 }));
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The upload route authorises the *user*; the URL it hands back authorises
|
|
173
|
+
nothing else. Keep `expiresIn` close to how long an upload should take, and
|
|
174
|
+
verify the object afterwards if the bucket's own policy does not.
|
|
175
|
+
|
|
176
|
+
## Next
|
|
177
|
+
|
|
178
|
+
- [Writing](writes.md) — the guards a presigned PUT bypasses.
|
|
179
|
+
- [Reading](reads.md) — `stat` and `exists`, for checking what was uploaded.
|
|
180
|
+
- [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`.
|