@ultimat3/storage 29.4.0 → 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 (47) hide show
  1. package/CLAUDE.md +25 -45
  2. package/README.md +102 -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-remove.ts +39 -0
  11. package/src/driver-local-stream.ts +104 -0
  12. package/src/driver-local-write.ts +28 -5
  13. package/src/driver-local.ts +250 -295
  14. package/src/driver-memory-multipart.ts +41 -0
  15. package/src/driver-memory.ts +205 -143
  16. package/src/driver-s3-client.ts +9 -0
  17. package/src/driver-s3-copy.ts +35 -0
  18. package/src/driver-s3-lock.ts +9 -2
  19. package/src/driver-s3-multipart-session.ts +87 -0
  20. package/src/driver-s3-multipart.ts +177 -0
  21. package/src/driver-s3-presign.ts +52 -0
  22. package/src/driver-s3-put-stream.ts +97 -0
  23. package/src/driver-s3-settle.ts +150 -0
  24. package/src/driver-s3-signed.ts +68 -3
  25. package/src/driver-s3-sse.ts +29 -0
  26. package/src/driver-s3.ts +89 -65
  27. package/src/driver.ts +86 -36
  28. package/src/errors-multipart.ts +35 -0
  29. package/src/errors-promote.ts +14 -0
  30. package/src/errors-sniff.ts +35 -0
  31. package/src/errors-stream.ts +62 -0
  32. package/src/errors.ts +13 -0
  33. package/src/expire.ts +169 -0
  34. package/src/grant-multipart.ts +196 -0
  35. package/src/grant.ts +42 -25
  36. package/src/image-warm.ts +84 -0
  37. package/src/image.ts +57 -0
  38. package/src/index.ts +75 -3
  39. package/src/multipart-staged.ts +127 -0
  40. package/src/multipart.ts +119 -0
  41. package/src/put-stream.ts +52 -0
  42. package/src/range.ts +35 -4
  43. package/src/signed-url.ts +59 -2
  44. package/src/subject-target.ts +82 -0
  45. package/src/upload-client-multipart.ts +156 -0
  46. package/src/upload-client.ts +21 -0
  47. 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,7 +49,15 @@ import {
42
49
  requestedLock,
43
50
  } from './object-lock';
44
51
  import { assertSafeKey } from './path';
45
- import { assertGetOptions, type GetOptions, servedRange } from './range';
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';
46
61
  import type { SignedUrlVerification } from './signed-url';
47
62
  import { buildSignedUrl, signedUrlBaseFor, verifySignedUrl } from './signed-url';
48
63
  import { resolveSigningSecret } from './signing-secret';
@@ -121,167 +136,214 @@ export function memoryStorageDriver(options: MemoryStorageDriverOptions = {}): M
121
136
  if (blocking !== undefined) throw keyConflict(registered, key, blocking);
122
137
  };
123
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
+
124
162
  const found = (key: string): Stored => {
125
163
  const hit = stored.get(key);
126
164
  if (hit === undefined) throw objectNotFound(registered, key);
127
165
  return hit;
128
166
  };
129
167
 
130
- return {
131
- name: DRIVER_NAME,
168
+ return withStagedMultipart(
169
+ {
170
+ name: DRIVER_NAME,
132
171
 
133
- get signedUrlBase(): string {
134
- return baseUrl;
135
- },
172
+ get signedUrlBase(): string {
173
+ return baseUrl;
174
+ },
136
175
 
137
- registerAs(diskName: string): void {
138
- registered = diskName;
139
- if (options.baseUrl === undefined) baseUrl = signedUrlBaseFor(diskName);
140
- },
176
+ registerAs(diskName: string): void {
177
+ registered = diskName;
178
+ if (options.baseUrl === undefined) baseUrl = signedUrlBaseFor(diskName);
179
+ },
141
180
 
142
- objects(): ReadonlyMap<string, Uint8Array> {
143
- return new Map([...stored].map(([key, value]) => [key, value.bytes.slice()]));
144
- },
181
+ objects(): ReadonlyMap<string, Uint8Array> {
182
+ return new Map([...stored].map(([key, value]) => [key, value.bytes.slice()]));
183
+ },
145
184
 
146
- async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
147
- const safe = assertSafeKey(key);
148
- // The same refusal both shipped disks make: a `put()` that succeeds in a test and throws in
149
- // production is a gap an app meets on the worst day.
150
- if (putOptions?.serverSideEncryption !== undefined) {
151
- throw new NotImplementedError({
152
- cause:
153
- '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',
154
- 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,
155
202
  });
156
- }
157
- assertPutOptions(DRIVER_NAME, putOptions);
158
- assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
159
- const bytes = await toBytes(body, { driver: DRIVER_NAME, key: safe, maxBytes: maxPutBytes });
160
- const claimed = putOptions?.checksum;
161
- if (claimed !== undefined) {
162
- const actual = sha256Base64(bytes);
163
- if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
164
- }
165
- const object: StorageObject = {
166
- key: safe,
167
- size: bytes.byteLength,
168
- contentType: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE,
169
- etag: etagOf(bytes),
170
- lastModified: clock.now(),
171
- ...(putOptions?.cacheControl === undefined
172
- ? {}
173
- : { cacheControl: putOptions.cacheControl }),
174
- ...(putOptions?.metadata === undefined ? {} : { metadata: putOptions.metadata }),
175
- };
176
- // Checked AFTER the body is read, with no await between check and write: the map is the
177
- // queue the local disk needs a `keyedQueue` for.
178
- refuseConflict(safe);
179
- refuseOverwrite(safe, 'overwrite');
180
- // A copy in, as a file write is: the caller's buffer and metadata are the caller's to reuse.
181
- stored.set(safe, {
182
- bytes: bytes.slice(),
183
- object: snapshot(object),
184
- lock: requestedLock(putOptions),
185
- });
186
- return snapshot(object);
187
- },
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
+ },
188
210
 
189
- async stat(key: string): Promise<StorageObject | undefined> {
190
- const hit = stored.get(assertSafeKey(key));
191
- return hit && snapshot(hit.object);
192
- },
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
+ },
193
235
 
194
- async get(key: string, getOptions?: GetOptions): Promise<StorageRead> {
195
- const safe = assertSafeKey(key);
196
- assertGetOptions(DRIVER_NAME, getOptions);
197
- const hit = found(safe);
198
- const range = getOptions?.range;
199
- if (range !== undefined) {
200
- const served = servedRange(DRIVER_NAME, safe, range, hit.bytes.byteLength, maxGetBytes);
201
- const bytes = hit.bytes.slice(served[0], served[1] + 1);
202
- return { object: snapshot(hit.object), bytes, range: served };
203
- }
204
- // The local disk's refusal, so a suite on this disk meets the ceiling production has.
205
- if (hit.bytes.byteLength > maxGetBytes) {
206
- throw getTooLarge(DRIVER_NAME, safe, hit.bytes.byteLength, maxGetBytes);
207
- }
208
- return { object: snapshot(hit.object), bytes: hit.bytes.slice() };
209
- },
236
+ async stat(key: string): Promise<StorageObject | undefined> {
237
+ const hit = stored.get(assertSafeKey(key));
238
+ return hit && snapshot(hit.object);
239
+ },
210
240
 
211
- async stream(key: string): Promise<ReadableStream<Uint8Array>> {
212
- const hit = found(assertSafeKey(key));
213
- return new Blob([hit.bytes.slice()]).stream();
214
- },
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
+ },
215
257
 
216
- async copy(from: string, to: string): Promise<StorageObject> {
217
- // Both keys before either lookup, as the other two disks do: an unsafe destination is the
218
- // refusal whether or not the source exists.
219
- const [from_, destination] = [assertSafeKey(from), assertSafeKey(to)];
220
- const source = found(from_);
221
- refuseConflict(destination);
222
- refuseOverwrite(destination, 'copy onto');
223
- const object: StorageObject = {
224
- ...source.object,
225
- key: destination,
226
- lastModified: clock.now(),
227
- };
228
- stored.set(destination, { bytes: source.bytes.slice(), object: snapshot(object) });
229
- return snapshot(object);
230
- },
258
+ async stream(key: string): Promise<ReadableStream<Uint8Array>> {
259
+ const hit = found(assertSafeKey(key));
260
+ return new Blob([hit.bytes.slice()]).stream();
261
+ },
231
262
 
232
- async delete(key: string): Promise<void> {
233
- const safe = assertSafeKey(key);
234
- const lock = lockAt(safe);
235
- if (lock !== undefined) {
236
- throw new ObjectLockedError({ disk: registered, key: safe, action: 'delete', lock });
237
- }
238
- stored.delete(safe);
239
- },
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
+ },
240
270
 
241
- async retentionOf(key: string): Promise<ObjectLock> {
242
- const lock = found(assertSafeKey(key)).lock;
243
- return lock === undefined ? { legalHold: false } : structuredClone(lock);
244
- },
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
+ },
245
289
 
246
- async exists(key: string): Promise<boolean> {
247
- return stored.has(assertSafeKey(key));
248
- },
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
+ },
249
298
 
250
- async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
251
- const prefix = listOptions?.prefix ?? '';
252
- assertListOptions(listOptions);
253
- const limit = resolveListLimit(listOptions?.limit);
254
- const cursor = listOptions?.cursor ?? undefined;
255
- // Code-unit order and "the cursor is the last key of the page before" — the local disk's own.
256
- const keys = [...stored.keys()]
257
- .filter((key) => key.startsWith(prefix) && (cursor === undefined || key > cursor))
258
- .sort();
259
- const page = keys.slice(0, limit);
260
- const objects = page.flatMap((key) => {
261
- const hit = stored.get(key);
262
- return hit === undefined ? [] : [snapshot(hit.object)];
263
- });
264
- return pageOf(objects, keys.length > page.length ? (page.at(-1) ?? null) : null);
265
- },
299
+ async retentionOf(key: string): Promise<ObjectLock> {
300
+ const lock = found(assertSafeKey(key)).lock;
301
+ return lock === undefined ? { legalHold: false } : structuredClone(lock);
302
+ },
266
303
 
267
- async signedUrl(key: string, urlOptions?: SignedUrlOptions): Promise<string> {
268
- return buildSignedUrl({
269
- secret,
270
- key: assertSafeKey(key),
271
- method: urlOptions?.method,
272
- expiresInMs: urlOptions?.expiresInMs,
273
- maxBytes: urlOptions?.maxBytes,
274
- contentType: urlOptions?.contentType,
275
- baseUrl,
276
- clock,
277
- });
278
- },
304
+ async exists(key: string): Promise<boolean> {
305
+ return stored.has(assertSafeKey(key));
306
+ },
279
307
 
280
- async verifySigned(input: {
281
- readonly url: string;
282
- readonly clock?: Clock | undefined;
283
- }): Promise<SignedUrlVerification> {
284
- 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
+ },
285
345
  },
286
- };
346
+ memoryPartStore(),
347
+ clock,
348
+ );
287
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
+ }