@ultimat3/storage 29.3.2 → 30.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.
Files changed (50) hide show
  1. package/CLAUDE.md +27 -47
  2. package/README.md +130 -12
  3. package/package.json +12 -2
  4. package/src/accept-part.ts +30 -0
  5. package/src/accept.ts +7 -0
  6. package/src/attachment.ts +99 -6
  7. package/src/byte-pump.ts +46 -0
  8. package/src/driver-local-list.ts +75 -0
  9. package/src/driver-local-multipart.ts +133 -0
  10. package/src/driver-local-read.ts +13 -0
  11. package/src/driver-local-remove.ts +39 -0
  12. package/src/driver-local-stream.ts +104 -0
  13. package/src/driver-local-write.ts +28 -5
  14. package/src/driver-local.ts +257 -283
  15. package/src/driver-memory-multipart.ts +41 -0
  16. package/src/driver-memory.ts +205 -135
  17. package/src/driver-s3-client.ts +9 -0
  18. package/src/driver-s3-copy.ts +35 -0
  19. package/src/driver-s3-lock.ts +9 -2
  20. package/src/driver-s3-multipart-session.ts +87 -0
  21. package/src/driver-s3-multipart.ts +177 -0
  22. package/src/driver-s3-presign.ts +52 -0
  23. package/src/driver-s3-put-stream.ts +97 -0
  24. package/src/driver-s3-range.ts +99 -0
  25. package/src/driver-s3-settle.ts +150 -0
  26. package/src/driver-s3-signed.ts +69 -4
  27. package/src/driver-s3-sse.ts +29 -0
  28. package/src/driver-s3.ts +113 -65
  29. package/src/driver.ts +98 -38
  30. package/src/errors-multipart.ts +35 -0
  31. package/src/errors-promote.ts +14 -0
  32. package/src/errors-range.ts +47 -0
  33. package/src/errors-sniff.ts +35 -0
  34. package/src/errors-stream.ts +62 -0
  35. package/src/errors.ts +16 -1
  36. package/src/expire.ts +169 -0
  37. package/src/grant-multipart.ts +196 -0
  38. package/src/grant.ts +42 -25
  39. package/src/image-warm.ts +84 -0
  40. package/src/image.ts +57 -0
  41. package/src/index.ts +81 -2
  42. package/src/multipart-staged.ts +127 -0
  43. package/src/multipart.ts +119 -0
  44. package/src/put-stream.ts +52 -0
  45. package/src/range.ts +102 -0
  46. package/src/signed-url.ts +59 -2
  47. package/src/subject-target.ts +82 -0
  48. package/src/upload-client-multipart.ts +156 -0
  49. package/src/upload-client.ts +21 -0
  50. package/src/upload.ts +107 -21
@@ -9,14 +9,17 @@
9
9
  import {
10
10
  type Clock,
11
11
  finiteCount,
12
+ isUltimateError,
12
13
  NotImplementedError,
13
14
  type Page,
14
15
  pageOf,
15
16
  systemClock,
16
17
  } from '@ultimat3/core';
18
+ import { pumpBounded } from './byte-pump';
17
19
  import {
18
20
  assertListOptions,
19
21
  assertPutOptions,
22
+ type CopyOptions,
20
23
  DEFAULT_CONTENT_TYPE,
21
24
  etagOf,
22
25
  type ListOptions,
@@ -29,11 +32,15 @@ import {
29
32
  type StorageObject,
30
33
  type StorageRead,
31
34
  sha256Base64,
35
+ signedByteBound,
32
36
  toBytes,
33
37
  } from './driver';
34
38
  import type { LocalDriverOptions } from './driver-local';
35
39
  import { keyInTheWay } from './driver-memory-conflict';
36
- import { checksumMismatch, getTooLarge, keyConflict, objectNotFound } from './errors';
40
+ import { memoryPartStore } from './driver-memory-multipart';
41
+ import { checksumMismatch, getTooLarge, keyConflict, objectNotFound, putFailed } from './errors';
42
+ import { objectChanged } from './errors-promote';
43
+ import { withStagedMultipart } from './multipart-staged';
37
44
  import {
38
45
  assertObjectLockOptions,
39
46
  isLocked,
@@ -42,6 +49,15 @@ import {
42
49
  requestedLock,
43
50
  } from './object-lock';
44
51
  import { assertSafeKey } from './path';
52
+ import { assertPutStream, type PutStreamOptions, streamLimit } from './put-stream';
53
+ import {
54
+ assertGetOptions,
55
+ assertStreamRange,
56
+ type ByteRange,
57
+ type GetOptions,
58
+ servedRange,
59
+ streamedRange,
60
+ } from './range';
45
61
  import type { SignedUrlVerification } from './signed-url';
46
62
  import { buildSignedUrl, signedUrlBaseFor, verifySignedUrl } from './signed-url';
47
63
  import { resolveSigningSecret } from './signing-secret';
@@ -120,160 +136,214 @@ export function memoryStorageDriver(options: MemoryStorageDriverOptions = {}): M
120
136
  if (blocking !== undefined) throw keyConflict(registered, key, blocking);
121
137
  };
122
138
 
139
+ /** Both writes end here, with no await between the checks and the write. */
140
+ const commit = (safe: string, bytes: Uint8Array, putOptions?: PutOptions): StorageObject => {
141
+ const object: StorageObject = {
142
+ key: safe,
143
+ size: bytes.byteLength,
144
+ contentType: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE,
145
+ etag: etagOf(bytes),
146
+ lastModified: clock.now(),
147
+ ...(putOptions?.cacheControl === undefined ? {} : { cacheControl: putOptions.cacheControl }),
148
+ ...(putOptions?.metadata === undefined ? {} : { metadata: putOptions.metadata }),
149
+ };
150
+ // Checked AFTER the body is read: the map is the queue the local disk needs a `keyedQueue` for.
151
+ refuseConflict(safe);
152
+ refuseOverwrite(safe, 'overwrite');
153
+ // A copy in, as a file write is: the caller's buffer and metadata are the caller's to reuse.
154
+ stored.set(safe, {
155
+ bytes: bytes.slice(),
156
+ object: snapshot(object),
157
+ lock: requestedLock(putOptions),
158
+ });
159
+ return snapshot(object);
160
+ };
161
+
123
162
  const found = (key: string): Stored => {
124
163
  const hit = stored.get(key);
125
164
  if (hit === undefined) throw objectNotFound(registered, key);
126
165
  return hit;
127
166
  };
128
167
 
129
- return {
130
- name: DRIVER_NAME,
168
+ return withStagedMultipart(
169
+ {
170
+ name: DRIVER_NAME,
131
171
 
132
- get signedUrlBase(): string {
133
- return baseUrl;
134
- },
172
+ get signedUrlBase(): string {
173
+ return baseUrl;
174
+ },
135
175
 
136
- registerAs(diskName: string): void {
137
- registered = diskName;
138
- if (options.baseUrl === undefined) baseUrl = signedUrlBaseFor(diskName);
139
- },
176
+ registerAs(diskName: string): void {
177
+ registered = diskName;
178
+ if (options.baseUrl === undefined) baseUrl = signedUrlBaseFor(diskName);
179
+ },
140
180
 
141
- objects(): ReadonlyMap<string, Uint8Array> {
142
- return new Map([...stored].map(([key, value]) => [key, value.bytes.slice()]));
143
- },
181
+ objects(): ReadonlyMap<string, Uint8Array> {
182
+ return new Map([...stored].map(([key, value]) => [key, value.bytes.slice()]));
183
+ },
144
184
 
145
- async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
146
- const safe = assertSafeKey(key);
147
- // The same refusal both shipped disks make: a `put()` that succeeds in a test and throws in
148
- // production is a gap an app meets on the worst day.
149
- if (putOptions?.serverSideEncryption !== undefined) {
150
- throw new NotImplementedError({
151
- cause:
152
- 'server-side encryption on the memory driver (it holds bytes in a Map) is not implemented by this driver — drop serverSideEncryption from put() and encrypt the disk itself: an s3Driver over a bucket with a default KMS rule',
153
- fix: 'put(key, body)',
185
+ async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
186
+ const safe = assertSafeKey(key);
187
+ // The same refusal both shipped disks make: a `put()` that succeeds in a test and throws in
188
+ // production is a gap an app meets on the worst day.
189
+ if (putOptions?.serverSideEncryption !== undefined) {
190
+ throw new NotImplementedError({
191
+ cause:
192
+ 'server-side encryption on the memory driver (it holds bytes in a Map) is not implemented by this driver — drop serverSideEncryption from put() and encrypt the disk itself: an s3Driver over a bucket with a default KMS rule',
193
+ fix: 'put(key, body)',
194
+ });
195
+ }
196
+ assertPutOptions(DRIVER_NAME, putOptions);
197
+ assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
198
+ const bytes = await toBytes(body, {
199
+ driver: DRIVER_NAME,
200
+ key: safe,
201
+ maxBytes: maxPutBytes,
154
202
  });
155
- }
156
- assertPutOptions(DRIVER_NAME, putOptions);
157
- assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
158
- const bytes = await toBytes(body, { driver: DRIVER_NAME, key: safe, maxBytes: maxPutBytes });
159
- const claimed = putOptions?.checksum;
160
- if (claimed !== undefined) {
161
- const actual = sha256Base64(bytes);
162
- if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
163
- }
164
- const object: StorageObject = {
165
- key: safe,
166
- size: bytes.byteLength,
167
- contentType: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE,
168
- etag: etagOf(bytes),
169
- lastModified: clock.now(),
170
- ...(putOptions?.cacheControl === undefined
171
- ? {}
172
- : { cacheControl: putOptions.cacheControl }),
173
- ...(putOptions?.metadata === undefined ? {} : { metadata: putOptions.metadata }),
174
- };
175
- // Checked AFTER the body is read, with no await between check and write: the map is the
176
- // queue the local disk needs a `keyedQueue` for.
177
- refuseConflict(safe);
178
- refuseOverwrite(safe, 'overwrite');
179
- // A copy in, as a file write is: the caller's buffer and metadata are the caller's to reuse.
180
- stored.set(safe, {
181
- bytes: bytes.slice(),
182
- object: snapshot(object),
183
- lock: requestedLock(putOptions),
184
- });
185
- return snapshot(object);
186
- },
203
+ const claimed = putOptions?.checksum;
204
+ if (claimed !== undefined) {
205
+ const actual = sha256Base64(bytes);
206
+ if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
207
+ }
208
+ return commit(safe, bytes, putOptions);
209
+ },
187
210
 
188
- async stat(key: string): Promise<StorageObject | undefined> {
189
- const hit = stored.get(assertSafeKey(key));
190
- return hit && snapshot(hit.object);
191
- },
211
+ /** A `Map` holds bytes, so "unbuffered" has no meaning here. The contract is the rest of it. */
212
+ async putStream(
213
+ key: string,
214
+ body: ReadableStream<Uint8Array>,
215
+ streamOptions: PutStreamOptions,
216
+ ): Promise<StorageObject> {
217
+ const safe = assertSafeKey(key);
218
+ const maxBytes = assertPutStream(DRIVER_NAME, body, streamOptions);
219
+ refuseOverwrite(safe, 'overwrite');
220
+ const chunks: Uint8Array[] = [];
221
+ const limit = streamLimit(DRIVER_NAME, safe, maxBytes);
222
+ await pumpBounded(body, limit, (chunk) => {
223
+ chunks.push(chunk);
224
+ }).catch((error: unknown) => {
225
+ if (isUltimateError(error)) throw error;
226
+ throw putFailed(
227
+ registered,
228
+ safe,
229
+ error,
230
+ 'await disk.putStream(key, stream, { maxBytes }) # with a fresh stream',
231
+ );
232
+ });
233
+ return commit(safe, new Uint8Array(Bun.concatArrayBuffers(chunks)), streamOptions);
234
+ },
192
235
 
193
- async get(key: string): Promise<StorageRead> {
194
- const safe = assertSafeKey(key);
195
- const hit = found(safe);
196
- // The local disk's refusal, so a suite on this disk meets the ceiling production has.
197
- if (hit.bytes.byteLength > maxGetBytes) {
198
- throw getTooLarge(DRIVER_NAME, safe, hit.bytes.byteLength, maxGetBytes);
199
- }
200
- return { object: snapshot(hit.object), bytes: hit.bytes.slice() };
201
- },
236
+ async stat(key: string): Promise<StorageObject | undefined> {
237
+ const hit = stored.get(assertSafeKey(key));
238
+ return hit && snapshot(hit.object);
239
+ },
202
240
 
203
- async stream(key: string): Promise<ReadableStream<Uint8Array>> {
204
- const hit = found(assertSafeKey(key));
205
- return new Blob([hit.bytes.slice()]).stream();
206
- },
241
+ async get(key: string, getOptions?: GetOptions): Promise<StorageRead> {
242
+ const safe = assertSafeKey(key);
243
+ assertGetOptions(DRIVER_NAME, getOptions);
244
+ const hit = found(safe);
245
+ const range = getOptions?.range;
246
+ if (range !== undefined) {
247
+ const served = servedRange(DRIVER_NAME, safe, range, hit.bytes.byteLength, maxGetBytes);
248
+ const bytes = hit.bytes.slice(served[0], served[1] + 1);
249
+ return { object: snapshot(hit.object), bytes, range: served };
250
+ }
251
+ // The local disk's refusal, so a suite on this disk meets the ceiling production has.
252
+ if (hit.bytes.byteLength > maxGetBytes) {
253
+ throw getTooLarge(DRIVER_NAME, safe, hit.bytes.byteLength, maxGetBytes);
254
+ }
255
+ return { object: snapshot(hit.object), bytes: hit.bytes.slice() };
256
+ },
207
257
 
208
- async copy(from: string, to: string): Promise<StorageObject> {
209
- // Both keys before either lookup, as the other two disks do: an unsafe destination is the
210
- // refusal whether or not the source exists.
211
- const [from_, destination] = [assertSafeKey(from), assertSafeKey(to)];
212
- const source = found(from_);
213
- refuseConflict(destination);
214
- refuseOverwrite(destination, 'copy onto');
215
- const object: StorageObject = {
216
- ...source.object,
217
- key: destination,
218
- lastModified: clock.now(),
219
- };
220
- stored.set(destination, { bytes: source.bytes.slice(), object: snapshot(object) });
221
- return snapshot(object);
222
- },
258
+ async stream(key: string): Promise<ReadableStream<Uint8Array>> {
259
+ const hit = found(assertSafeKey(key));
260
+ return new Blob([hit.bytes.slice()]).stream();
261
+ },
223
262
 
224
- async delete(key: string): Promise<void> {
225
- const safe = assertSafeKey(key);
226
- const lock = lockAt(safe);
227
- if (lock !== undefined) {
228
- throw new ObjectLockedError({ disk: registered, key: safe, action: 'delete', lock });
229
- }
230
- stored.delete(safe);
231
- },
263
+ async streamRange(key: string, range: ByteRange): Promise<ReadableStream<Uint8Array>> {
264
+ const safe = assertSafeKey(key);
265
+ assertStreamRange(DRIVER_NAME, range);
266
+ const hit = found(safe);
267
+ const [first, last] = streamedRange(DRIVER_NAME, safe, range, hit.bytes.byteLength);
268
+ return new Blob([hit.bytes.slice(first, last + 1)]).stream();
269
+ },
232
270
 
233
- async retentionOf(key: string): Promise<ObjectLock> {
234
- const lock = found(assertSafeKey(key)).lock;
235
- return lock === undefined ? { legalHold: false } : structuredClone(lock);
236
- },
271
+ async copy(from: string, to: string, options?: CopyOptions): Promise<StorageObject> {
272
+ // Both keys before either lookup, as the other two disks do: an unsafe destination is the
273
+ // refusal whether or not the source exists.
274
+ const [from_, destination] = [assertSafeKey(from), assertSafeKey(to)];
275
+ const source = found(from_);
276
+ if (options?.ifMatch !== undefined && source.object.etag !== options.ifMatch) {
277
+ throw objectChanged(from_, options.ifMatch, source.object.etag);
278
+ }
279
+ refuseConflict(destination);
280
+ refuseOverwrite(destination, 'copy onto');
281
+ const object: StorageObject = {
282
+ ...source.object,
283
+ key: destination,
284
+ lastModified: clock.now(),
285
+ };
286
+ stored.set(destination, { bytes: source.bytes.slice(), object: snapshot(object) });
287
+ return snapshot(object);
288
+ },
237
289
 
238
- async exists(key: string): Promise<boolean> {
239
- return stored.has(assertSafeKey(key));
240
- },
290
+ async delete(key: string): Promise<void> {
291
+ const safe = assertSafeKey(key);
292
+ const lock = lockAt(safe);
293
+ if (lock !== undefined) {
294
+ throw new ObjectLockedError({ disk: registered, key: safe, action: 'delete', lock });
295
+ }
296
+ stored.delete(safe);
297
+ },
241
298
 
242
- async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
243
- const prefix = listOptions?.prefix ?? '';
244
- assertListOptions(listOptions);
245
- const limit = resolveListLimit(listOptions?.limit);
246
- const cursor = listOptions?.cursor ?? undefined;
247
- // Code-unit order and "the cursor is the last key of the page before" — the local disk's own.
248
- const keys = [...stored.keys()]
249
- .filter((key) => key.startsWith(prefix) && (cursor === undefined || key > cursor))
250
- .sort();
251
- const page = keys.slice(0, limit);
252
- const objects = page.flatMap((key) => {
253
- const hit = stored.get(key);
254
- return hit === undefined ? [] : [snapshot(hit.object)];
255
- });
256
- return pageOf(objects, keys.length > page.length ? (page.at(-1) ?? null) : null);
257
- },
299
+ async retentionOf(key: string): Promise<ObjectLock> {
300
+ const lock = found(assertSafeKey(key)).lock;
301
+ return lock === undefined ? { legalHold: false } : structuredClone(lock);
302
+ },
258
303
 
259
- async signedUrl(key: string, urlOptions?: SignedUrlOptions): Promise<string> {
260
- return buildSignedUrl({
261
- secret,
262
- key: assertSafeKey(key),
263
- method: urlOptions?.method,
264
- expiresInMs: urlOptions?.expiresInMs,
265
- maxBytes: urlOptions?.maxBytes,
266
- contentType: urlOptions?.contentType,
267
- baseUrl,
268
- clock,
269
- });
270
- },
304
+ async exists(key: string): Promise<boolean> {
305
+ return stored.has(assertSafeKey(key));
306
+ },
271
307
 
272
- async verifySigned(input: {
273
- readonly url: string;
274
- readonly clock?: Clock | undefined;
275
- }): Promise<SignedUrlVerification> {
276
- return verifySignedUrl({ url: input.url, secret, baseUrl, clock: input.clock ?? clock });
308
+ async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
309
+ const prefix = listOptions?.prefix ?? '';
310
+ assertListOptions(listOptions);
311
+ const limit = resolveListLimit(listOptions?.limit);
312
+ const cursor = listOptions?.cursor ?? undefined;
313
+ // Code-unit order and "the cursor is the last key of the page before" — the local disk's own.
314
+ const keys = [...stored.keys()]
315
+ .filter((key) => key.startsWith(prefix) && (cursor === undefined || key > cursor))
316
+ .sort();
317
+ const page = keys.slice(0, limit);
318
+ const objects = page.flatMap((key) => {
319
+ const hit = stored.get(key);
320
+ return hit === undefined ? [] : [snapshot(hit.object)];
321
+ });
322
+ return pageOf(objects, keys.length > page.length ? (page.at(-1) ?? null) : null);
323
+ },
324
+
325
+ async signedUrl(key: string, urlOptions?: SignedUrlOptions): Promise<string> {
326
+ return buildSignedUrl({
327
+ secret,
328
+ key: assertSafeKey(key),
329
+ method: urlOptions?.method,
330
+ expiresInMs: urlOptions?.expiresInMs,
331
+ maxBytes: signedByteBound(DRIVER_NAME, urlOptions),
332
+ contentType: urlOptions?.contentType,
333
+ part: urlOptions?.part,
334
+ baseUrl,
335
+ clock,
336
+ });
337
+ },
338
+
339
+ async verifySigned(input: {
340
+ readonly url: string;
341
+ readonly clock?: Clock | undefined;
342
+ }): Promise<SignedUrlVerification> {
343
+ return verifySignedUrl({ url: input.url, secret, baseUrl, clock: input.clock ?? clock });
344
+ },
277
345
  },
278
- };
346
+ memoryPartStore(),
347
+ clock,
348
+ );
279
349
  }
@@ -14,6 +14,8 @@ export interface S3FileLike {
14
14
  exists(): Promise<boolean>;
15
15
  delete(): Promise<void>;
16
16
  stream(): ReadableStream<Uint8Array>;
17
+ /** Bun's `S3File.slice`: `end` exclusive. Its `stream()` sends one `Range:`, measured on 1.4.4. */
18
+ slice(begin: number, end: number): { stream(): ReadableStream<Uint8Array> };
17
19
  stat(): Promise<S3StatLike>;
18
20
  presign(options: { method?: string; expiresIn?: number; type?: string }): string;
19
21
  }
@@ -92,3 +94,10 @@ export function buildClient(options: S3DriverOptions): S3ClientLike {
92
94
  ...clientTarget(options),
93
95
  });
94
96
  }
97
+
98
+ /**
99
+ * A missing `LastModified` is ABSENT, never `new Date(0)`: epoch 0 is older than every window, so
100
+ * `sweepOrphans` deleted an upload on the strength of a field the provider never sent.
101
+ */
102
+ export const dated = (value: string | Date | undefined): { readonly lastModified?: Date } =>
103
+ value === undefined ? {} : { lastModified: value instanceof Date ? value : new Date(value) };
@@ -0,0 +1,35 @@
1
+ // Single responsibility: the s3 copy that names a revision. `Bun.S3Client`'s `write` takes no
2
+ // condition, and `x-amz-copy-source-if-match` is what lets the provider refuse an overwrite in the
3
+ // one moment this process cannot see, so a revision-bound copy is a signed CopyObject.
4
+
5
+ import { type S3Refusal, type S3WireOptions, sendSigned, xmlField } from './driver-s3-signed';
6
+
7
+ /**
8
+ * The provider's refusal, or `undefined` when the copy landed. A 200 is not yet a verdict: S3 sends
9
+ * its headers before a long copy ends, so a failure can arrive as an `<Error>` inside a 200's body.
10
+ */
11
+ export async function signedCopy(
12
+ options: S3WireOptions,
13
+ source: string,
14
+ destination: string,
15
+ ifMatch: string,
16
+ ): Promise<S3Refusal | undefined> {
17
+ const path = source.split('/').map(encodeURIComponent).join('/');
18
+ const result = await sendSigned(options, {
19
+ method: 'PUT',
20
+ key: destination,
21
+ headers: {
22
+ 'x-amz-copy-source': `/${encodeURIComponent(options.bucket)}/${path}`,
23
+ 'x-amz-copy-source-if-match': ifMatch,
24
+ },
25
+ });
26
+ if (!result.ok) return result.refusal;
27
+ const body = await result.response.text();
28
+ if (!body.includes('<Error>')) return undefined;
29
+ return {
30
+ name: 'S3Error',
31
+ code: xmlField(body, 'Code') ?? 'InternalError',
32
+ message: xmlField(body, 'Message') ?? 'the copy failed after it was accepted',
33
+ statusCode: 500,
34
+ };
35
+ }
@@ -25,12 +25,19 @@ export function needsSignedPut(options: PutOptions | undefined): boolean {
25
25
 
26
26
  /** Every header the signed PUT sends beyond the signer's own, screened before a byte moves. */
27
27
  export function putHeaders(bytes: Uint8Array, options: PutOptions): Record<string, string> {
28
- const headers: Record<string, string> = {
29
- 'content-type': options.contentType ?? DEFAULT_CONTENT_TYPE,
28
+ return {
29
+ ...objectHeaders(options),
30
30
  // Required by S3 on any PUT that sets a retention, and an integrity check on every other one:
31
31
  // a body that changed in flight is `BadDigest`, never a stored object.
32
32
  'content-md5': new Bun.CryptoHasher('md5').update(bytes).digest('base64'),
33
33
  };
34
+ }
35
+
36
+ /** What describes the object rather than one body: a PUT's headers, and a multipart Create's. */
37
+ export function objectHeaders(options: PutOptions): Record<string, string> {
38
+ const headers: Record<string, string> = {
39
+ 'content-type': options.contentType ?? DEFAULT_CONTENT_TYPE,
40
+ };
34
41
  if (options.cacheControl !== undefined) {
35
42
  assert(
36
43
  HEADER_VALUE.test(options.cacheControl),
@@ -0,0 +1,87 @@
1
+ // Single responsibility: the s3 disk's `MultipartCapability` — the browser's parts go straight to
2
+ // the bucket on URLs core's signer presigns, and every other step is one signed call. No bytes
3
+ // pass through this process, so the web role never carries them.
4
+
5
+ import type { StorageDriver, StorageObject } from './driver';
6
+ import {
7
+ abortS3Upload,
8
+ completeS3Upload,
9
+ createS3Upload,
10
+ listS3Parts,
11
+ listS3Uploads,
12
+ } from './driver-s3-multipart';
13
+ import type { S3Refusal, S3WireOptions } from './driver-s3-signed';
14
+ import { objectNotFound } from './errors';
15
+ import { partsInvalid, uploadUnknown } from './errors-multipart';
16
+ import type { MultipartCapability } from './multipart';
17
+ import { assertSafeKey } from './path';
18
+
19
+ export interface S3MultipartInput {
20
+ readonly wire: S3WireOptions;
21
+ /** The disk: it presigns the part URLs and stats the completed object. */
22
+ readonly driver: StorageDriver;
23
+ /** The disk's coded write refusal for a key. */
24
+ readonly refusedWrite: (key: string) => (refusal: unknown) => never;
25
+ }
26
+
27
+ /** S3's answer for an id that is not an open upload: never opened, completed or aborted. */
28
+ const isUnknownUpload = (refusal: S3Refusal): boolean => refusal.code === 'NoSuchUpload';
29
+ /** S3's refusals of a Complete whose parts do not match what it holds. */
30
+ const PART_REFUSALS: ReadonlySet<string> = new Set([
31
+ 'InvalidPart',
32
+ 'InvalidPartOrder',
33
+ 'EntityTooSmall',
34
+ ]);
35
+
36
+ export function s3Multipart(input: S3MultipartInput): MultipartCapability {
37
+ const { wire, driver } = input;
38
+ const target = (key: string, uploadId?: string) => ({
39
+ wire,
40
+ key,
41
+ refused: (refusal: S3Refusal): never => {
42
+ if (uploadId !== undefined && isUnknownUpload(refusal)) {
43
+ throw uploadUnknown(driver.name, key, uploadId);
44
+ }
45
+ if (uploadId !== undefined && PART_REFUSALS.has(refusal.code)) {
46
+ throw partsInvalid(key, uploadId, refusal.message);
47
+ }
48
+ return input.refusedWrite(key)(refusal);
49
+ },
50
+ });
51
+
52
+ return {
53
+ create: (key, { contentType }) =>
54
+ createS3Upload(target(assertSafeKey(key)), { 'content-type': contentType }),
55
+
56
+ signPart: (key, uploadId, partNumber, { contentLength, expiresInMs }) =>
57
+ driver.signedUrl(key, {
58
+ method: 'PUT',
59
+ contentLength,
60
+ expiresInMs,
61
+ part: { uploadId, partNumber },
62
+ }),
63
+
64
+ listParts: (key, uploadId) => listS3Parts(target(assertSafeKey(key), uploadId), uploadId),
65
+
66
+ async complete(key, uploadId, parts): Promise<StorageObject> {
67
+ const safe = assertSafeKey(key);
68
+ await completeS3Upload(target(safe, uploadId), uploadId, parts);
69
+ const object = await driver.stat(safe);
70
+ if (object === undefined) throw objectNotFound(driver.name, safe);
71
+ return object;
72
+ },
73
+
74
+ async abort(key, uploadId): Promise<void> {
75
+ const safe = assertSafeKey(key);
76
+ const refusal = await abortS3Upload(target(safe), uploadId);
77
+ // Gone already is the state asked for; anything else is a refusal the caller must hear.
78
+ if (refusal !== undefined && !isUnknownUpload(refusal)) input.refusedWrite(safe)(refusal);
79
+ },
80
+
81
+ listUploads: (prefix) =>
82
+ listS3Uploads(
83
+ { wire, refused: (refusal) => input.refusedWrite(prefix ?? '')(refusal) },
84
+ prefix,
85
+ ),
86
+ };
87
+ }