@ultimat3/storage 1.1.0 → 2.0.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/CLAUDE.md +169 -0
- package/README.md +156 -7
- package/package.json +3 -2
- package/src/accept.ts +125 -0
- package/src/attachment.ts +213 -0
- package/src/driver-local.ts +185 -27
- package/src/driver-s3-fixture.ts +164 -0
- package/src/driver-s3.ts +142 -25
- package/src/driver.ts +126 -11
- package/src/errors.ts +169 -2
- package/src/grant.ts +100 -0
- package/src/index.ts +63 -1
- package/src/path.ts +23 -1
- package/src/signed-url.ts +34 -16
- package/src/upload-client.ts +172 -0
- package/src/upload.ts +9 -7
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The in-memory `Bun.S3Client` stand-in the s3 driver's tests drive, and the helpers that read
|
|
2
|
+
// its answers. Shared rather than copied because `driver-s3.test.ts` (reads, copies, listings,
|
|
3
|
+
// credentials) and `driver-s3-put.test.ts` (everything `put()` refuses) must exercise the SAME
|
|
4
|
+
// fake — two fakes drifting apart would be two providers, agreeing only by construction.
|
|
5
|
+
|
|
6
|
+
import type { S3ClientLike, S3FileLike, S3ListResultLike, S3StatLike } from './driver-s3';
|
|
7
|
+
import { isStorageError, objectNotFound } from './errors';
|
|
8
|
+
|
|
9
|
+
/** The driver's private `DRIVER_NAME`; the fake reports failures against the same disk. */
|
|
10
|
+
export const FAKE_DISK = 's3';
|
|
11
|
+
|
|
12
|
+
export interface FakeObject {
|
|
13
|
+
bytes: Uint8Array;
|
|
14
|
+
type?: string | undefined;
|
|
15
|
+
etag?: string | undefined;
|
|
16
|
+
lastModified?: string | Date | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PresignCall {
|
|
20
|
+
key: string;
|
|
21
|
+
options: {
|
|
22
|
+
method?: string | undefined;
|
|
23
|
+
expiresIn?: number | undefined;
|
|
24
|
+
type?: string | undefined;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ListCall {
|
|
29
|
+
prefix?: string | undefined;
|
|
30
|
+
maxKeys?: number | undefined;
|
|
31
|
+
continuationToken?: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What Bun hands back when the SERVICE refuses: an ordinary `Error` named `S3Error`, carrying the
|
|
36
|
+
* provider's own code and status. The driver must read those structurally, because nothing in the
|
|
37
|
+
* type system says a caught value has them.
|
|
38
|
+
*/
|
|
39
|
+
export function s3Error(code: string, statusCode: number, key: string): Error {
|
|
40
|
+
return Object.assign(new Error(`${code}: the fake provider refused ${key}`), {
|
|
41
|
+
name: 'S3Error',
|
|
42
|
+
code,
|
|
43
|
+
statusCode,
|
|
44
|
+
path: key,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** In-memory stand-in for `Bun.S3Client`, driven through `S3ClientLike` — no socket, ever. */
|
|
49
|
+
export class FakeS3Client implements S3ClientLike {
|
|
50
|
+
readonly store = new Map<string, FakeObject>();
|
|
51
|
+
readonly fileCalls: string[] = [];
|
|
52
|
+
readonly presignCalls: PresignCall[] = [];
|
|
53
|
+
readonly listCalls: ListCall[] = [];
|
|
54
|
+
listResult: S3ListResultLike = { contents: [] };
|
|
55
|
+
/** A provider REFUSAL (403/503/reset) — the failure `delete()` must surface, not swallow. */
|
|
56
|
+
failDeleteFor: string | undefined;
|
|
57
|
+
/** A provider "there is nothing there" — the one failure `delete()` may call success. */
|
|
58
|
+
absentDeleteFor: string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* A refused LISTING — a denied `s3:ListBucket`, a throttle, an expired credential. There is no
|
|
61
|
+
* "absent" counterpart: a prefix with no keys is an empty page, never a rejection, so every
|
|
62
|
+
* failure `list()` can meet is one it must surface.
|
|
63
|
+
*/
|
|
64
|
+
failListWith: Error | undefined;
|
|
65
|
+
|
|
66
|
+
/** Which key each handed-out `S3FileLike` stands for, so `write(sourceFile)` can read it. */
|
|
67
|
+
private readonly fileKeys = new WeakMap<object, string>();
|
|
68
|
+
|
|
69
|
+
/** The bytes behind an `S3FileLike` passed as `write`'s data, or `undefined` for raw bytes. */
|
|
70
|
+
sourceOf(data: unknown): Uint8Array | undefined {
|
|
71
|
+
if (typeof data !== 'object' || data === null) return undefined;
|
|
72
|
+
const sourceKey = this.fileKeys.get(data);
|
|
73
|
+
return sourceKey === undefined ? undefined : this.store.get(sourceKey)?.bytes;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
file(key: string): S3FileLike {
|
|
77
|
+
this.fileCalls.push(key);
|
|
78
|
+
const store = this.store;
|
|
79
|
+
const client = this;
|
|
80
|
+
const handle: S3FileLike = {
|
|
81
|
+
async write(data, options) {
|
|
82
|
+
// `copy()` hands the SOURCE S3File to write(), exactly as Bun's own union allows, so the
|
|
83
|
+
// fake has to read one back out of its store rather than assume bytes.
|
|
84
|
+
const source = client.sourceOf(data);
|
|
85
|
+
const bytes =
|
|
86
|
+
source !== undefined
|
|
87
|
+
? source
|
|
88
|
+
: data instanceof Uint8Array
|
|
89
|
+
? data
|
|
90
|
+
: new Uint8Array(await (data as Blob).arrayBuffer());
|
|
91
|
+
store.set(key, { bytes, type: options?.type });
|
|
92
|
+
return bytes.byteLength;
|
|
93
|
+
},
|
|
94
|
+
async arrayBuffer() {
|
|
95
|
+
const entry = store.get(key);
|
|
96
|
+
// Reads the way the provider does: a GET on a key that is not there is a 404, and the
|
|
97
|
+
// driver is expected to have gated it behind exists() before ever getting here.
|
|
98
|
+
if (entry === undefined) throw objectNotFound(FAKE_DISK, key);
|
|
99
|
+
return new Uint8Array(entry.bytes).buffer;
|
|
100
|
+
},
|
|
101
|
+
async exists() {
|
|
102
|
+
return store.has(key);
|
|
103
|
+
},
|
|
104
|
+
async delete() {
|
|
105
|
+
// Shaped like the real thing: an `S3Error` is a plain Error with `name: 'S3Error'`, a
|
|
106
|
+
// provider `code` and a status. Deliberately NOT a StorageError — the driver has to
|
|
107
|
+
// classify a rejection it never authored, which is the only kind a provider hands back.
|
|
108
|
+
if (client.absentDeleteFor === key) throw s3Error('NoSuchKey', 404, key);
|
|
109
|
+
if (client.failDeleteFor === key) throw s3Error('AccessDenied', 403, key);
|
|
110
|
+
store.delete(key);
|
|
111
|
+
},
|
|
112
|
+
stream() {
|
|
113
|
+
const entry = store.get(key);
|
|
114
|
+
const bytes = entry?.bytes ?? new Uint8Array();
|
|
115
|
+
return new ReadableStream<Uint8Array>({
|
|
116
|
+
start(controller) {
|
|
117
|
+
controller.enqueue(bytes);
|
|
118
|
+
controller.close();
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
async stat(): Promise<S3StatLike> {
|
|
123
|
+
const entry = store.get(key);
|
|
124
|
+
if (entry === undefined) throw objectNotFound(FAKE_DISK, key);
|
|
125
|
+
return {
|
|
126
|
+
size: entry.bytes.byteLength,
|
|
127
|
+
type: entry.type,
|
|
128
|
+
etag: entry.etag,
|
|
129
|
+
lastModified: entry.lastModified,
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
presign(options) {
|
|
133
|
+
client.presignCalls.push({ key, options });
|
|
134
|
+
return `https://fake.example/${key}?signed`;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
this.fileKeys.set(handle, key);
|
|
138
|
+
return handle;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async list(input: ListCall): Promise<S3ListResultLike> {
|
|
142
|
+
this.listCalls.push(input);
|
|
143
|
+
if (this.failListWith !== undefined) throw this.failListWith;
|
|
144
|
+
return this.listResult;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const bytesOf = (text: string): Uint8Array => new TextEncoder().encode(text);
|
|
149
|
+
export const textOf = (bytes: Uint8Array): string => new TextDecoder().decode(bytes);
|
|
150
|
+
|
|
151
|
+
/** The error code a call answered with, or how it failed to answer with one. */
|
|
152
|
+
export function codeOf(caught: unknown): string {
|
|
153
|
+
return isStorageError(caught) ? caught.code : `not-a-storage-error: ${String(caught)}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The thrown value itself, where the assertion is about its `cause`/`fix` and not just its code. */
|
|
157
|
+
export async function catchError(fn: () => Promise<unknown>): Promise<unknown> {
|
|
158
|
+
try {
|
|
159
|
+
await fn();
|
|
160
|
+
return undefined;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return error;
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/driver-s3.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// The client is built lazily on first use so importing this module never opens a socket, and
|
|
4
4
|
// credentials arrive as env var NAMES: a literal key in app.config.ts is a key in git.
|
|
5
5
|
|
|
6
|
-
import { ConfigInvalidError, EnvMissingError } from '@ultimat3/core';
|
|
6
|
+
import { ConfigInvalidError, EnvMissingError, stringField } from '@ultimat3/core';
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_CONTENT_TYPE,
|
|
9
9
|
DEFAULT_LIST_LIMIT,
|
|
@@ -13,19 +13,28 @@ import {
|
|
|
13
13
|
type SignedUrlOptions,
|
|
14
14
|
type StorageBody,
|
|
15
15
|
type StorageDriver,
|
|
16
|
+
type StorageListEntry,
|
|
16
17
|
type StorageObject,
|
|
17
18
|
type StorageRead,
|
|
18
19
|
sha256Base64,
|
|
19
20
|
toBytes,
|
|
20
21
|
} from './driver';
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
checksumMismatch,
|
|
24
|
+
deleteFailed,
|
|
25
|
+
listFailed,
|
|
26
|
+
objectNotFound,
|
|
27
|
+
storageNotImplemented,
|
|
28
|
+
} from './errors';
|
|
22
29
|
import { assertSafeKey } from './path';
|
|
30
|
+
import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
|
|
23
31
|
|
|
24
32
|
const DRIVER_NAME = 's3';
|
|
25
33
|
|
|
26
34
|
/** Structural view of `Bun.S3Client` — typing it here keeps `bun-types` out of the contract. */
|
|
27
35
|
export interface S3FileLike {
|
|
28
|
-
write
|
|
36
|
+
/** `S3FileLike` is in the union because Bun's own `write` takes an `S3File` — that is `copy`. */
|
|
37
|
+
write(data: Uint8Array | Blob | S3FileLike, options?: { type?: string }): Promise<number>;
|
|
29
38
|
arrayBuffer(): Promise<ArrayBuffer>;
|
|
30
39
|
exists(): Promise<boolean>;
|
|
31
40
|
delete(): Promise<void>;
|
|
@@ -79,6 +88,13 @@ export interface S3DriverOptions {
|
|
|
79
88
|
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
80
89
|
/** Injected in tests; production constructs `Bun.S3Client`. */
|
|
81
90
|
readonly client?: S3ClientLike | undefined;
|
|
91
|
+
/**
|
|
92
|
+
* Ceiling on ONE server-side `put()`, because `put()` buffers the whole body. Defaults to the
|
|
93
|
+
* upload policy's ceiling — the same number for the same fact. Raise it for a disk that really
|
|
94
|
+
* does write large objects from the server; a user upload belongs on `grantUpload` instead,
|
|
95
|
+
* and S3's single-PUT limit is 5GB regardless of what this says.
|
|
96
|
+
*/
|
|
97
|
+
readonly maxPutBytes?: number | undefined;
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
interface S3ClientConstructor {
|
|
@@ -137,7 +153,63 @@ function buildClient(options: S3DriverOptions): S3ClientLike {
|
|
|
137
153
|
const toDate = (value: string | Date | undefined): Date =>
|
|
138
154
|
value === undefined ? new Date(0) : value instanceof Date ? value : new Date(value);
|
|
139
155
|
|
|
156
|
+
/** One numeric field off a value that may fight being read — `stringField`'s missing twin. */
|
|
157
|
+
function numberField(value: unknown, key: string): number | undefined {
|
|
158
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
159
|
+
try {
|
|
160
|
+
const held = (value as Record<string, unknown>)[key];
|
|
161
|
+
return typeof held === 'number' ? held : undefined;
|
|
162
|
+
} catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The provider codes that mean "there is nothing at that key", and nothing wider. */
|
|
168
|
+
const ABSENT_OBJECT_CODES: ReadonlySet<string> = new Set(['NoSuchKey', 'NotFound', 'ENOENT']);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The ONE delete failure the contract calls success. `AccessDenied`, `SlowDown`, an expired
|
|
172
|
+
* credential and a reset connection are none of them, and the previous `.catch(() => undefined)`
|
|
173
|
+
* reported all four as deleted — which is how an erasure sweep certifies data it never removed.
|
|
174
|
+
*
|
|
175
|
+
* Read structurally: an `S3Error` is `name: 'S3Error'` with a `code` the service returned, and
|
|
176
|
+
* every field of a value this process did not build is a getter that can throw.
|
|
177
|
+
*/
|
|
178
|
+
function isAbsentObject(error: unknown): boolean {
|
|
179
|
+
const code = stringField(error, 'code');
|
|
180
|
+
if (code !== undefined && ABSENT_OBJECT_CODES.has(code)) return true;
|
|
181
|
+
return numberField(error, 'statusCode') === 404 || numberField(error, 'status') === 404;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Everything `put()` may be handed that this driver cannot honour, refused before a byte moves.
|
|
186
|
+
* A typed refusal at the call site is the whole point: `serverSideEncryption` exists on
|
|
187
|
+
* `PutOptions` so that "this disk cannot prove per-object encryption" is something an engineer
|
|
188
|
+
* meets while writing the call, not while answering a security review.
|
|
189
|
+
*/
|
|
190
|
+
function refuseUnsupportedPut(bucket: string, key: string, putOptions?: PutOptions): void {
|
|
191
|
+
if (putOptions?.metadata !== undefined || putOptions?.cacheControl !== undefined) {
|
|
192
|
+
const uri = `s3://${bucket}/${key}`;
|
|
193
|
+
throw storageNotImplemented(
|
|
194
|
+
'user metadata and cache-control on the s3 driver (Bun exposes no header hook yet)',
|
|
195
|
+
`drop metadata/cacheControl from put(), or set them out of band: ` +
|
|
196
|
+
`aws s3 cp ${uri} ${uri} --metadata-directive REPLACE`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const sse = putOptions?.serverSideEncryption;
|
|
200
|
+
if (sse === undefined) return;
|
|
201
|
+
const rule =
|
|
202
|
+
sse.algorithm === 'aws:kms'
|
|
203
|
+
? `{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":"${sse.kmsKeyId ?? '<key-arn>'}"}`
|
|
204
|
+
: '{"SSEAlgorithm":"AES256"}';
|
|
205
|
+
throw storageNotImplemented(
|
|
206
|
+
'per-object server-side encryption on the s3 driver (Bun.S3Client exposes acl, storageClass and type, and nothing for x-amz-server-side-encryption)',
|
|
207
|
+
`set it bucket-wide, then drop serverSideEncryption from put(): aws s3api put-bucket-encryption --bucket ${bucket} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":${rule},"BucketKeyEnabled":true}]}'`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
140
211
|
export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
212
|
+
const maxPutBytes = options.maxPutBytes ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
141
213
|
let client: S3ClientLike | undefined;
|
|
142
214
|
const conn = (): S3ClientLike => {
|
|
143
215
|
client ??= buildClient(options);
|
|
@@ -160,17 +232,12 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
160
232
|
|
|
161
233
|
async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
|
|
162
234
|
const safe = assertSafeKey(key);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
// Buffered on purpose: size and checksum must be known before the object exists.
|
|
172
|
-
// Multipart streaming upload is a later optimisation, not a correctness change.
|
|
173
|
-
const bytes = await toBytes(body);
|
|
235
|
+
refuseUnsupportedPut(options.bucket, safe, putOptions);
|
|
236
|
+
// Buffered on purpose: size and checksum must be known before the object exists — so this
|
|
237
|
+
// path is for objects that FIT IN MEMORY, and `maxPutBytes` is what makes that a contract
|
|
238
|
+
// rather than a hope. User uploads never come through here: they go direct to the bucket
|
|
239
|
+
// via `grantUpload`, which is the architecture, not a later optimisation.
|
|
240
|
+
const bytes = await toBytes(body, { driver: DRIVER_NAME, key: safe, maxBytes: maxPutBytes });
|
|
174
241
|
const claimed = putOptions?.checksum;
|
|
175
242
|
if (claimed !== undefined) {
|
|
176
243
|
const actual = sha256Base64(bytes);
|
|
@@ -197,11 +264,40 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
197
264
|
return file.stream();
|
|
198
265
|
},
|
|
199
266
|
|
|
200
|
-
|
|
267
|
+
/**
|
|
268
|
+
* Bytes from one key to another without a round trip through this process. Bun exposes no
|
|
269
|
+
* `CopyObject`, so the source `S3File` is handed to `write()` — Bun's own union accepts one —
|
|
270
|
+
* and the bytes move inside Bun rather than through the app's heap. That is the half of the
|
|
271
|
+
* win available today: promoting a 500MB attachment used to be `get()` + `put()`, a full GB
|
|
272
|
+
* resident in the pod. A true server-side copy needs a Bun API that does not exist in 1.3.
|
|
273
|
+
*/
|
|
274
|
+
async copy(from: string, to: string): Promise<StorageObject> {
|
|
275
|
+
const source = assertSafeKey(from);
|
|
276
|
+
const destination = assertSafeKey(to);
|
|
277
|
+
const file = conn().file(source);
|
|
278
|
+
if (!(await file.exists())) throw objectNotFound(DRIVER_NAME, source);
|
|
279
|
+
const stat = await file.stat();
|
|
201
280
|
await conn()
|
|
202
|
-
.file(
|
|
203
|
-
.
|
|
204
|
-
|
|
281
|
+
.file(destination)
|
|
282
|
+
.write(file, { type: stat.type ?? DEFAULT_CONTENT_TYPE });
|
|
283
|
+
return statObject(destination);
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
async delete(key: string): Promise<void> {
|
|
287
|
+
const safe = assertSafeKey(key);
|
|
288
|
+
try {
|
|
289
|
+
await conn().file(safe).delete();
|
|
290
|
+
} catch (error) {
|
|
291
|
+
// Idempotent means an ABSENT key, and nothing else. AWS answers DELETE on a missing key
|
|
292
|
+
// with 204, so this branch is for the providers that do not.
|
|
293
|
+
if (isAbsentObject(error)) return;
|
|
294
|
+
throw deleteFailed(
|
|
295
|
+
DRIVER_NAME,
|
|
296
|
+
safe,
|
|
297
|
+
error,
|
|
298
|
+
`grant s3:DeleteObject on this prefix to the app's role, then reproduce with the provider's own words: aws s3api delete-object --bucket ${options.bucket} --key ${safe}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
205
301
|
},
|
|
206
302
|
|
|
207
303
|
async exists(key: string): Promise<boolean> {
|
|
@@ -209,18 +305,39 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
209
305
|
},
|
|
210
306
|
|
|
211
307
|
async list(listOptions?: ListOptions): Promise<ListPage> {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
308
|
+
const prefix = listOptions?.prefix ?? '';
|
|
309
|
+
// `conn()` OUTSIDE the try: a missing credential or an absent `Bun.S3Client` is this disk
|
|
310
|
+
// misconfigured, and it already answers with its own code and its own fix.
|
|
311
|
+
const client = conn();
|
|
312
|
+
let result: S3ListResultLike;
|
|
313
|
+
try {
|
|
314
|
+
result = await client.list({
|
|
315
|
+
maxKeys: listOptions?.limit ?? DEFAULT_LIST_LIMIT,
|
|
316
|
+
...(listOptions?.prefix === undefined ? {} : { prefix: listOptions.prefix }),
|
|
317
|
+
...(listOptions?.cursor === undefined ? {} : { continuationToken: listOptions.cursor }),
|
|
318
|
+
});
|
|
319
|
+
} catch (error) {
|
|
320
|
+
// A bare `S3Error` used to escape here, uncoded: no `X_*`, no `fix`, no `--json` shape, and
|
|
321
|
+
// `@ultimat3/http`'s error map has nothing to turn it into but a 500. A denied
|
|
322
|
+
// `s3:ListBucket` is the commonest one and reads to an operator as an app crash.
|
|
323
|
+
throw listFailed(
|
|
324
|
+
DRIVER_NAME,
|
|
325
|
+
prefix,
|
|
326
|
+
error,
|
|
327
|
+
`grant s3:ListBucket on this bucket to the app's role, then reproduce with the provider's own words: aws s3api list-objects-v2 --bucket ${options.bucket}${prefix === '' ? '' : ` --prefix ${prefix}`}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
const objects: StorageListEntry[] = [];
|
|
218
331
|
for (const entry of result.contents ?? []) {
|
|
219
332
|
if (entry.key === undefined) continue;
|
|
333
|
+
// No `contentType`. ListObjectsV2 does not return one, and reading it for real would
|
|
334
|
+
// cost one HeadObject per listed row — which is what `list()` exists to avoid. It used
|
|
335
|
+
// to report `application/octet-stream`, indistinguishable from an object that really is
|
|
336
|
+
// one, while the local driver reported the truth from its sidecar: a caller filtering a
|
|
337
|
+
// listing by content type got everything on `local` and nothing on `s3`. Absent now.
|
|
220
338
|
objects.push({
|
|
221
339
|
key: entry.key,
|
|
222
340
|
size: entry.size ?? 0,
|
|
223
|
-
contentType: DEFAULT_CONTENT_TYPE,
|
|
224
341
|
etag: entry.eTag ?? '',
|
|
225
342
|
lastModified: toDate(entry.lastModified),
|
|
226
343
|
});
|
package/src/driver.ts
CHANGED
|
@@ -1,15 +1,48 @@
|
|
|
1
1
|
// Single responsibility: the driver contract every disk implements, plus the byte helpers all
|
|
2
|
-
// drivers share (body normalisation, SHA-256 etag/checksum). Bytes never travel as
|
|
3
|
-
// a base64 round trip through JSON is how a "small" upload becomes a 33%-larger OOM
|
|
2
|
+
// drivers share (BOUNDED body normalisation, SHA-256 etag/checksum). Bytes never travel as
|
|
3
|
+
// strings — a base64 round trip through JSON is how a "small" upload becomes a 33%-larger OOM —
|
|
4
|
+
// and never unbounded, which is the same failure with the sender choosing the size.
|
|
5
|
+
|
|
6
|
+
import { putTooLarge } from './errors';
|
|
4
7
|
|
|
5
8
|
export type StorageBody = Uint8Array | ReadableStream<Uint8Array> | Blob;
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
/**
|
|
11
|
+
* One row of a listing — everything a listing can HONESTLY know. `contentType` is optional here
|
|
12
|
+
* and required on `StorageObject` below, because S3's `ListObjectsV2` does not return one: the
|
|
13
|
+
* s3 driver used to fill in `application/octet-stream`, the local driver read the real value out
|
|
14
|
+
* of its sidecar, and a caller filtering a listing by content type therefore got every object on
|
|
15
|
+
* `local` and none on `s3`. Absent now means "this listing cannot know" — `get()` can.
|
|
16
|
+
*/
|
|
17
|
+
export interface StorageListEntry {
|
|
8
18
|
readonly key: string;
|
|
9
19
|
readonly size: number;
|
|
10
|
-
|
|
20
|
+
/** Absent means the driver's listing does not carry it. Never a fabricated default. */
|
|
21
|
+
readonly contentType?: string | undefined;
|
|
11
22
|
readonly etag: string;
|
|
12
23
|
readonly lastModified: Date;
|
|
24
|
+
/** Present only when the driver actually stored what `put()` was handed — never invented. */
|
|
25
|
+
readonly cacheControl?: string | undefined;
|
|
26
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A single object the driver has actually looked at, so the content type is known. */
|
|
30
|
+
export interface StorageObject extends StorageListEntry {
|
|
31
|
+
readonly contentType: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Encryption at rest, per object. Declared so the gap is visible at the type level: an engineer
|
|
36
|
+
* asked "can you prove which key encrypted this object?" must meet the answer at the call site,
|
|
37
|
+
* not by reading the driver. NO driver honours it yet — `Bun.S3Client` exposes `acl`,
|
|
38
|
+
* `storageClass` and `type` and nothing for `x-amz-server-side-encryption*`, and a POSIX file is
|
|
39
|
+
* not encrypted at all — so both drivers refuse a `put()` carrying one (`X_NOT_IMPLEMENTED`) with
|
|
40
|
+
* the out-of-band bucket-default command in the `fix`. A typed refusal beats a silent absence.
|
|
41
|
+
*/
|
|
42
|
+
export interface ServerSideEncryption {
|
|
43
|
+
readonly algorithm: 'AES256' | 'aws:kms';
|
|
44
|
+
/** The customer-managed key. Only meaningful with `aws:kms`. */
|
|
45
|
+
readonly kmsKeyId?: string | undefined;
|
|
13
46
|
}
|
|
14
47
|
|
|
15
48
|
export interface PutOptions {
|
|
@@ -18,6 +51,8 @@ export interface PutOptions {
|
|
|
18
51
|
readonly metadata?: Readonly<Record<string, string>> | undefined;
|
|
19
52
|
/** base64 SHA-256 of the body. Supplied means verified: a mismatch is a rejected write. */
|
|
20
53
|
readonly checksum?: string | undefined;
|
|
54
|
+
/** Refused by every shipped driver — see `ServerSideEncryption`. */
|
|
55
|
+
readonly serverSideEncryption?: ServerSideEncryption | undefined;
|
|
21
56
|
}
|
|
22
57
|
|
|
23
58
|
export interface ListOptions {
|
|
@@ -28,7 +63,7 @@ export interface ListOptions {
|
|
|
28
63
|
}
|
|
29
64
|
|
|
30
65
|
export interface ListPage {
|
|
31
|
-
readonly objects: readonly
|
|
66
|
+
readonly objects: readonly StorageListEntry[];
|
|
32
67
|
readonly truncated: boolean;
|
|
33
68
|
/** Absent when `truncated` is false. */
|
|
34
69
|
readonly cursor?: string | undefined;
|
|
@@ -44,7 +79,17 @@ export type SignedUrlMethod = 'GET' | 'PUT';
|
|
|
44
79
|
export interface SignedUrlOptions {
|
|
45
80
|
readonly method?: SignedUrlMethod | undefined;
|
|
46
81
|
readonly expiresInMs?: number | undefined;
|
|
47
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Ceiling on a `PUT`, and **the one option whose enforcement is the driver's, not the caller's**.
|
|
84
|
+
*
|
|
85
|
+
* `localDriver` signs it into the URL, so `acceptSignedUpload` refuses a client that widens it.
|
|
86
|
+
* `s3Driver` CANNOT: S3 has no request header for a size and Bun's `presign` covers method,
|
|
87
|
+
* expiry and content type only — so the client PUTs straight into the bucket and nothing between
|
|
88
|
+
* the grant and the object enforces this number. It is not refused there, because `grantUpload`
|
|
89
|
+
* supplies it on every grant and refusing would break every s3 upload an app mints; the ceiling
|
|
90
|
+
* a bucket-backed disk actually gets is a bucket rule or a post-upload check on `object.size`.
|
|
91
|
+
* `driver-parity.test.ts` pins both halves so neither moves alone.
|
|
92
|
+
*/
|
|
48
93
|
readonly maxBytes?: number | undefined;
|
|
49
94
|
readonly contentType?: string | undefined;
|
|
50
95
|
}
|
|
@@ -56,7 +101,17 @@ export interface StorageDriver {
|
|
|
56
101
|
get(key: string): Promise<StorageRead>;
|
|
57
102
|
/** Bytes without buffering — the only safe path for anything over a few MB. */
|
|
58
103
|
stream(key: string): Promise<ReadableStream<Uint8Array>>;
|
|
59
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Bytes from one key to another without them passing through this process. Idempotent in the
|
|
106
|
+
* destination and non-destructive in the source: the caller deletes the source afterwards if
|
|
107
|
+
* it wanted a move. `X_STORAGE_NOT_FOUND` when `from` is absent.
|
|
108
|
+
*/
|
|
109
|
+
copy(from: string, to: string): Promise<StorageObject>;
|
|
110
|
+
/**
|
|
111
|
+
* Deleting an ABSENT key is not an error. Anything else is: a denied `s3:DeleteObject`, a
|
|
112
|
+
* throttle, an expired credential and a read-only mount all raise `X_STORAGE_DELETE_FAILED`,
|
|
113
|
+
* because a caller that cannot tell "gone" from "refused" reports data erased that is not.
|
|
114
|
+
*/
|
|
60
115
|
delete(key: string): Promise<void>;
|
|
61
116
|
exists(key: string): Promise<boolean>;
|
|
62
117
|
list(options?: ListOptions): Promise<ListPage>;
|
|
@@ -66,10 +121,70 @@ export interface StorageDriver {
|
|
|
66
121
|
export const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
|
|
67
122
|
export const DEFAULT_LIST_LIMIT = 1000;
|
|
68
123
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
124
|
+
/** What `toBytes` needs to refuse a body: the ceiling, and the key to name in the refusal. */
|
|
125
|
+
export interface ByteLimit {
|
|
126
|
+
readonly driver: string;
|
|
127
|
+
readonly key: string;
|
|
128
|
+
readonly maxBytes: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Body → bytes, refusing past the ceiling BEFORE the whole body is resident.
|
|
133
|
+
*
|
|
134
|
+
* The bound is not an optimisation. `put()` buffers, so without one the heap grows by whatever a
|
|
135
|
+
* route piped into it: a 4GB request body on a pod with a 768Mi limit is an OOM kill, and the
|
|
136
|
+
* caller sees a dropped connection rather than a refusal. A `Uint8Array` and a `Blob` already
|
|
137
|
+
* know their length, so they are refused without a copy; a stream is read a chunk at a time and
|
|
138
|
+
* cancelled the moment the running total passes the ceiling, so at most one chunk past the limit
|
|
139
|
+
* is ever held. The server-side `put()` path is for objects that FIT IN MEMORY — user uploads go
|
|
140
|
+
* direct to the disk through `grantUpload`, which is the architecture, not the optimisation.
|
|
141
|
+
*/
|
|
142
|
+
export async function toBytes(body: StorageBody, limit: ByteLimit): Promise<Uint8Array> {
|
|
143
|
+
if (body instanceof Uint8Array) {
|
|
144
|
+
if (body.byteLength > limit.maxBytes) {
|
|
145
|
+
throw putTooLarge(limit.driver, limit.key, body.byteLength, limit.maxBytes);
|
|
146
|
+
}
|
|
147
|
+
return body;
|
|
148
|
+
}
|
|
149
|
+
if (body instanceof Blob) {
|
|
150
|
+
if (body.size > limit.maxBytes) {
|
|
151
|
+
throw putTooLarge(limit.driver, limit.key, body.size, limit.maxBytes);
|
|
152
|
+
}
|
|
153
|
+
return new Uint8Array(await body.arrayBuffer());
|
|
154
|
+
}
|
|
155
|
+
return readBounded(body, limit);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The stream half of `toBytes`: the only shape whose length is unknown until it is read. */
|
|
159
|
+
async function readBounded(
|
|
160
|
+
body: ReadableStream<Uint8Array>,
|
|
161
|
+
limit: ByteLimit,
|
|
162
|
+
): Promise<Uint8Array> {
|
|
163
|
+
const reader = body.getReader();
|
|
164
|
+
const chunks: Uint8Array[] = [];
|
|
165
|
+
let total = 0;
|
|
166
|
+
try {
|
|
167
|
+
for (;;) {
|
|
168
|
+
const next = await reader.read();
|
|
169
|
+
if (next.done) break;
|
|
170
|
+
total += next.value.byteLength;
|
|
171
|
+
if (total > limit.maxBytes) {
|
|
172
|
+
throw putTooLarge(limit.driver, limit.key, total, limit.maxBytes);
|
|
173
|
+
}
|
|
174
|
+
chunks.push(next.value);
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
// Cancel rather than merely release: a refused body must stop arriving, not keep filling a
|
|
178
|
+
// socket buffer nobody will read. Already-closed streams answer this with a no-op.
|
|
179
|
+
await reader.cancel().catch(() => undefined);
|
|
180
|
+
}
|
|
181
|
+
const bytes = new Uint8Array(total);
|
|
182
|
+
let offset = 0;
|
|
183
|
+
for (const chunk of chunks) {
|
|
184
|
+
bytes.set(chunk, offset);
|
|
185
|
+
offset += chunk.byteLength;
|
|
186
|
+
}
|
|
187
|
+
return bytes;
|
|
73
188
|
}
|
|
74
189
|
|
|
75
190
|
/** base64 SHA-256 — the wire form for `PutOptions.checksum` and `ValidatedUpload.checksum`. */
|