@panorama-industries/effect-vercel-blob 0.0.0 → 0.2.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -1
  3. package/dist/client.d.ts +8 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +8 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/contracts/blob-client.d.ts +67 -0
  8. package/dist/contracts/blob-client.d.ts.map +1 -0
  9. package/dist/contracts/blob-client.js +11 -0
  10. package/dist/contracts/blob-client.js.map +1 -0
  11. package/dist/contracts/blob-config.d.ts +114 -0
  12. package/dist/contracts/blob-config.d.ts.map +1 -0
  13. package/dist/contracts/blob-config.js +7 -0
  14. package/dist/contracts/blob-config.js.map +1 -0
  15. package/dist/contracts/blob-domain.d.ts +336 -0
  16. package/dist/contracts/blob-domain.d.ts.map +1 -0
  17. package/dist/contracts/blob-domain.js +152 -0
  18. package/dist/contracts/blob-domain.js.map +1 -0
  19. package/dist/contracts/blob-errors.d.ts +125 -0
  20. package/dist/contracts/blob-errors.d.ts.map +1 -0
  21. package/dist/contracts/blob-errors.js +114 -0
  22. package/dist/contracts/blob-errors.js.map +1 -0
  23. package/dist/contracts/blob-signed-urls.d.ts +83 -0
  24. package/dist/contracts/blob-signed-urls.d.ts.map +1 -0
  25. package/dist/contracts/blob-signed-urls.js +10 -0
  26. package/dist/contracts/blob-signed-urls.js.map +1 -0
  27. package/dist/contracts/blob-store.d.ts +153 -0
  28. package/dist/contracts/blob-store.d.ts.map +1 -0
  29. package/dist/contracts/blob-store.js +15 -0
  30. package/dist/contracts/blob-store.js.map +1 -0
  31. package/dist/contracts/blob-uploads.d.ts +101 -0
  32. package/dist/contracts/blob-uploads.d.ts.map +1 -0
  33. package/dist/contracts/blob-uploads.js +17 -0
  34. package/dist/contracts/blob-uploads.js.map +1 -0
  35. package/dist/implementation/blob-client-live.d.ts +37 -0
  36. package/dist/implementation/blob-client-live.d.ts.map +1 -0
  37. package/dist/implementation/blob-client-live.js +213 -0
  38. package/dist/implementation/blob-client-live.js.map +1 -0
  39. package/dist/implementation/blob-config-live.d.ts +49 -0
  40. package/dist/implementation/blob-config-live.d.ts.map +1 -0
  41. package/dist/implementation/blob-config-live.js +82 -0
  42. package/dist/implementation/blob-config-live.js.map +1 -0
  43. package/dist/implementation/blob-live.d.ts +16 -0
  44. package/dist/implementation/blob-live.d.ts.map +1 -0
  45. package/dist/implementation/blob-live.js +16 -0
  46. package/dist/implementation/blob-live.js.map +1 -0
  47. package/dist/implementation/blob-signed-urls-live.d.ts +12 -0
  48. package/dist/implementation/blob-signed-urls-live.d.ts.map +1 -0
  49. package/dist/implementation/blob-signed-urls-live.js +92 -0
  50. package/dist/implementation/blob-signed-urls-live.js.map +1 -0
  51. package/dist/implementation/blob-store-live.d.ts +18 -0
  52. package/dist/implementation/blob-store-live.d.ts.map +1 -0
  53. package/dist/implementation/blob-store-live.js +364 -0
  54. package/dist/implementation/blob-store-live.js.map +1 -0
  55. package/dist/implementation/blob-uploads-live.d.ts +11 -0
  56. package/dist/implementation/blob-uploads-live.d.ts.map +1 -0
  57. package/dist/implementation/blob-uploads-live.js +218 -0
  58. package/dist/implementation/blob-uploads-live.js.map +1 -0
  59. package/dist/index.d.ts +15 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +15 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/internal/blob-errors.d.ts +79 -0
  64. package/dist/internal/blob-errors.d.ts.map +1 -0
  65. package/dist/internal/blob-errors.js +196 -0
  66. package/dist/internal/blob-errors.js.map +1 -0
  67. package/dist/internal/blob-request.d.ts +255 -0
  68. package/dist/internal/blob-request.d.ts.map +1 -0
  69. package/dist/internal/blob-request.js +291 -0
  70. package/dist/internal/blob-request.js.map +1 -0
  71. package/dist/internal/callback.d.ts +70 -0
  72. package/dist/internal/callback.d.ts.map +1 -0
  73. package/dist/internal/callback.js +141 -0
  74. package/dist/internal/callback.js.map +1 -0
  75. package/dist/internal/client-token.d.ts +65 -0
  76. package/dist/internal/client-token.d.ts.map +1 -0
  77. package/dist/internal/client-token.js +80 -0
  78. package/dist/internal/client-token.js.map +1 -0
  79. package/dist/internal/env.d.ts +34 -0
  80. package/dist/internal/env.d.ts.map +1 -0
  81. package/dist/internal/env.js +61 -0
  82. package/dist/internal/env.js.map +1 -0
  83. package/dist/internal/multipart.d.ts +110 -0
  84. package/dist/internal/multipart.d.ts.map +1 -0
  85. package/dist/internal/multipart.js +290 -0
  86. package/dist/internal/multipart.js.map +1 -0
  87. package/dist/internal/presign.d.ts +123 -0
  88. package/dist/internal/presign.d.ts.map +1 -0
  89. package/dist/internal/presign.js +345 -0
  90. package/dist/internal/presign.js.map +1 -0
  91. package/dist/internal/store-id.d.ts +38 -0
  92. package/dist/internal/store-id.d.ts.map +1 -0
  93. package/dist/internal/store-id.js +54 -0
  94. package/dist/internal/store-id.js.map +1 -0
  95. package/dist/internal/urls.d.ts +26 -0
  96. package/dist/internal/urls.d.ts.map +1 -0
  97. package/dist/internal/urls.js +33 -0
  98. package/dist/internal/urls.js.map +1 -0
  99. package/dist/internal/wire.d.ts +55 -0
  100. package/dist/internal/wire.d.ts.map +1 -0
  101. package/dist/internal/wire.js +64 -0
  102. package/dist/internal/wire.js.map +1 -0
  103. package/dist/internal/write-headers.d.ts +56 -0
  104. package/dist/internal/write-headers.d.ts.map +1 -0
  105. package/dist/internal/write-headers.js +82 -0
  106. package/dist/internal/write-headers.js.map +1 -0
  107. package/dist/testing/index.d.ts +2 -0
  108. package/dist/testing/index.d.ts.map +1 -0
  109. package/dist/testing/index.js +2 -0
  110. package/dist/testing/index.js.map +1 -0
  111. package/dist/testing/test-blob-store.d.ts +134 -0
  112. package/dist/testing/test-blob-store.d.ts.map +1 -0
  113. package/dist/testing/test-blob-store.js +718 -0
  114. package/dist/testing/test-blob-store.js.map +1 -0
  115. package/package.json +58 -4
  116. package/src/client.ts +11 -0
  117. package/src/contracts/blob-client.ts +121 -0
  118. package/src/contracts/blob-config.ts +64 -0
  119. package/src/contracts/blob-domain.ts +304 -0
  120. package/src/contracts/blob-errors.ts +161 -0
  121. package/src/contracts/blob-signed-urls.ts +112 -0
  122. package/src/contracts/blob-store.ts +258 -0
  123. package/src/contracts/blob-uploads.ts +151 -0
  124. package/src/implementation/blob-client-live.ts +387 -0
  125. package/src/implementation/blob-config-live.ts +145 -0
  126. package/src/implementation/blob-live.ts +30 -0
  127. package/src/implementation/blob-signed-urls-live.ts +121 -0
  128. package/src/implementation/blob-store-live.ts +528 -0
  129. package/src/implementation/blob-uploads-live.ts +326 -0
  130. package/src/index.ts +18 -0
  131. package/src/internal/blob-errors.ts +325 -0
  132. package/src/internal/blob-request.ts +617 -0
  133. package/src/internal/callback.ts +200 -0
  134. package/src/internal/client-token.ts +132 -0
  135. package/src/internal/env.ts +128 -0
  136. package/src/internal/multipart.ts +526 -0
  137. package/src/internal/presign.ts +477 -0
  138. package/src/internal/store-id.ts +69 -0
  139. package/src/internal/urls.ts +42 -0
  140. package/src/internal/wire.ts +80 -0
  141. package/src/internal/write-headers.ts +118 -0
  142. package/src/testing/index.ts +1 -0
  143. package/src/testing/test-blob-store.ts +1136 -0
@@ -0,0 +1,526 @@
1
+ import { Duration, Effect, Stream, SynchronizedRef } from "effect";
2
+
3
+ import type {
4
+ BlobBody,
5
+ BlobPathname,
6
+ OnUploadProgress,
7
+ PutBlobResult,
8
+ UploadProgress,
9
+ } from "../contracts/blob-domain.ts";
10
+ import type { BlobApiError, BlobDomainErrorTag } from "./blob-errors.ts";
11
+ import type { BlobRequestAuth, BlobRequester } from "./blob-request.ts";
12
+ import type { WriteHeaderOptions, WriteOption } from "./write-headers.ts";
13
+
14
+ import { MultipartUpload, UploadedPart } from "../contracts/blob-domain.ts";
15
+ import { BlobUploadRejected, FileTooLarge } from "../contracts/blob-errors.ts";
16
+ import { bodyLength, uploadProgressThrottle } from "./blob-request.ts";
17
+ import { CreateMultipartUploadResponse, PutBlobResponse, UploadPartResponse } from "./wire.ts";
18
+ import { allowedWriteOptions, cacheControlMaxAgeSeconds, writeHeaders } from "./write-headers.ts";
19
+
20
+ /*
21
+ * Multipart uploads, shared by the server `BlobStore` and the browser `BlobClient`: the manual
22
+ * primitives (create, upload a part, complete) and the automatic upload behind `put({ multipart })`,
23
+ * ported from the SDK's `multipart/*.ts` and `uncontrolledMultipartUpload`.
24
+ */
25
+
26
+ /** Size of the parts an automatic multipart upload sends, as the SDK's `partSizeInBytes`. */
27
+ export const multipartPartSize = 8 * 1024 * 1024;
28
+
29
+ /** Minimum time between progress events of an automatic multipart upload, as in the SDK. */
30
+ export const multipartProgressInterval = Duration.millis(150);
31
+
32
+ /**
33
+ * How many parts an automatic multipart upload sends at once, as the SDK's `maxConcurrentUploads`:
34
+ * 6 in browsers (where `window` is defined), which allow about six connections per host, and 8
35
+ * elsewhere. Read on every call.
36
+ */
37
+ export const maxConcurrentPartUploads = (): number =>
38
+ (globalThis as { readonly window?: unknown }).window === undefined ? 8 : 6;
39
+
40
+ /**
41
+ * Which write options become headers at each step. Creation (and every request of an automatic
42
+ * upload, which the SDK sends with the `put` headers throughout) uses `create`; `uploadPart` and
43
+ * `completeMultipartUpload` rebuild their headers from the {@link MultipartUpload} handle with
44
+ * `part`.
45
+ */
46
+ export interface MultipartWriteOptions {
47
+ readonly create: ReadonlyArray<WriteOption>;
48
+ readonly part: ReadonlyArray<WriteOption>;
49
+ }
50
+
51
+ /** The SDK's server lists: every option at creation, all but `ifMatch` for parts and completion. */
52
+ export const serverMultipartWriteOptions: MultipartWriteOptions = {
53
+ create: allowedWriteOptions.put,
54
+ part: allowedWriteOptions.multipartPart,
55
+ };
56
+
57
+ /** The SDK's client-token lists: only `contentType` (and `access`); the token fixes the rest. */
58
+ export const clientMultipartWriteOptions: MultipartWriteOptions = {
59
+ create: allowedWriteOptions.client,
60
+ part: allowedWriteOptions.client,
61
+ };
62
+
63
+ /**
64
+ * Where and how multipart requests are sent. Build it once per layer for the server; the browser
65
+ * client builds one per call because its auth (client token or presigned payload) is per upload.
66
+ *
67
+ * @template EA - Failure of `auth`, such as `OidcTokenUnavailable`.
68
+ * @template RA - Requirements of `auth`.
69
+ * @template K - Domain errors the calling service's contract declares for writes.
70
+ */
71
+ export interface MultipartTarget<EA, RA, K extends BlobDomainErrorTag> {
72
+ readonly requester: BlobRequester;
73
+
74
+ /** Evaluated once per request, so refreshed OIDC tokens are picked up between parts. */
75
+ readonly auth: Effect.Effect<BlobRequestAuth, EA, RA>;
76
+
77
+ /**
78
+ * Prefix of the operation names in errors and spans: `BlobStore` or `BlobClient`. Requests are
79
+ * named `<service>.createMultipartUpload`, `<service>.uploadPart` and
80
+ * `<service>.completeMultipartUpload`.
81
+ */
82
+ readonly service: string;
83
+
84
+ readonly domainErrors: ReadonlyArray<K>;
85
+
86
+ readonly writeOptions: MultipartWriteOptions;
87
+ }
88
+
89
+ /** A part as the complete request sends it; `UploadedPart` and plain records both fit. */
90
+ export interface CompletedPart {
91
+ readonly etag: string;
92
+ readonly partNumber: number;
93
+ }
94
+
95
+ const mpuPath = (pathname: string): string =>
96
+ `/mpu?${new URLSearchParams({ pathname }).toString()}`;
97
+
98
+ /** The handle's write options, filtered to what creation sent, with the cache lifetime in seconds. */
99
+ const handleWriteOptions = (
100
+ allowed: ReadonlyArray<WriteOption>,
101
+ options: WriteHeaderOptions,
102
+ ): Omit<ConstructorParameters<typeof MultipartUpload>[0], "pathname" | "key" | "uploadId"> => ({
103
+ access: options.access,
104
+ ...(allowed.includes("addRandomSuffix") && options.addRandomSuffix !== undefined
105
+ ? { addRandomSuffix: options.addRandomSuffix }
106
+ : {}),
107
+ ...(allowed.includes("allowOverwrite") && options.allowOverwrite !== undefined
108
+ ? { allowOverwrite: options.allowOverwrite }
109
+ : {}),
110
+ ...(allowed.includes("contentType") && options.contentType
111
+ ? { contentType: options.contentType }
112
+ : {}),
113
+ ...(allowed.includes("cacheControlMaxAge") && options.cacheControlMaxAge !== undefined
114
+ ? { cacheControlMaxAge: cacheControlMaxAgeSeconds(options.cacheControlMaxAge) }
115
+ : {}),
116
+ });
117
+
118
+ /** The write options a handle carries, back in the form `writeHeaders` reads. */
119
+ const uploadWriteOptions = (upload: MultipartUpload): WriteHeaderOptions => ({
120
+ access: upload.access,
121
+ addRandomSuffix: upload.addRandomSuffix,
122
+ allowOverwrite: upload.allowOverwrite,
123
+ contentType: upload.contentType,
124
+ cacheControlMaxAge:
125
+ upload.cacheControlMaxAge === undefined
126
+ ? undefined
127
+ : Duration.seconds(upload.cacheControlMaxAge),
128
+ });
129
+
130
+ /** One protocol step, with the write headers already computed. */
131
+ interface Step<EA, RA, K extends BlobDomainErrorTag> {
132
+ readonly target: MultipartTarget<EA, RA, K>;
133
+ readonly pathname: string;
134
+ readonly headers: Readonly<Record<string, string>>;
135
+ }
136
+
137
+ const createRequest = <EA, RA, K extends BlobDomainErrorTag>({
138
+ target,
139
+ pathname,
140
+ headers,
141
+ }: Step<EA, RA, K>): Effect.Effect<
142
+ typeof CreateMultipartUploadResponse.Type,
143
+ BlobApiError<K> | EA,
144
+ RA
145
+ > =>
146
+ target.requester.call({
147
+ operation: `${target.service}.createMultipartUpload`,
148
+ method: "POST",
149
+ path: mpuPath(pathname),
150
+ auth: target.auth,
151
+ response: CreateMultipartUploadResponse,
152
+ headers: { ...headers, "x-mpu-action": "create" },
153
+ locator: pathname,
154
+ domainErrors: target.domainErrors,
155
+ });
156
+
157
+ const uploadPartRequest = <EA, RA, K extends BlobDomainErrorTag, E = never, R = never>(
158
+ { target, pathname, headers }: Step<EA, RA, K>,
159
+ part: {
160
+ readonly key: string;
161
+ readonly uploadId: string;
162
+ readonly partNumber: number;
163
+ readonly body: BlobBody<E, R>;
164
+ readonly onUploadProgress?: OnUploadProgress | undefined;
165
+ /** Sent as `content-type`; automatic parts are typed `application/octet-stream` like the SDK's. */
166
+ readonly contentType?: string | undefined;
167
+ },
168
+ ): Effect.Effect<typeof UploadPartResponse.Type, BlobApiError<K> | EA | E, RA | R> =>
169
+ target.requester.call({
170
+ operation: `${target.service}.uploadPart`,
171
+ method: "POST",
172
+ path: mpuPath(pathname),
173
+ auth: target.auth,
174
+ response: UploadPartResponse,
175
+ headers: {
176
+ ...headers,
177
+ "x-mpu-action": "upload",
178
+ // The key can hold any UTF-8 character, and header values must be US-ASCII.
179
+ "x-mpu-key": encodeURIComponent(part.key),
180
+ "x-mpu-upload-id": part.uploadId,
181
+ "x-mpu-part-number": String(part.partNumber),
182
+ ...(part.contentType === undefined ? {} : { "content-type": part.contentType }),
183
+ },
184
+ body: part.body,
185
+ locator: pathname,
186
+ domainErrors: target.domainErrors,
187
+ onUploadProgress: part.onUploadProgress,
188
+ });
189
+
190
+ const completeRequest = <EA, RA, K extends BlobDomainErrorTag>(
191
+ { target, pathname, headers }: Step<EA, RA, K>,
192
+ upload: {
193
+ readonly key: string;
194
+ readonly uploadId: string;
195
+ readonly parts: ReadonlyArray<CompletedPart>;
196
+ /**
197
+ * Serialize parts as `{ partNumber, etag }`, the field order of the SDK's automatic upload;
198
+ * manual completion passes the caller's `{ etag, partNumber }` through.
199
+ */
200
+ readonly partNumberFirst?: boolean | undefined;
201
+ },
202
+ ): Effect.Effect<PutBlobResult, BlobApiError<K> | EA, RA> =>
203
+ target.requester.call({
204
+ operation: `${target.service}.completeMultipartUpload`,
205
+ method: "POST",
206
+ path: mpuPath(pathname),
207
+ auth: target.auth,
208
+ response: PutBlobResponse,
209
+ headers: {
210
+ ...headers,
211
+ "x-mpu-action": "complete",
212
+ "x-mpu-upload-id": upload.uploadId,
213
+ "x-mpu-key": encodeURIComponent(upload.key),
214
+ },
215
+ // `content-type: application/json` comes with `json`; fields in the SDK's order.
216
+ json: upload.parts.map(({ etag, partNumber }) =>
217
+ upload.partNumberFirst === true ? { partNumber, etag } : { etag, partNumber },
218
+ ),
219
+ locator: pathname,
220
+ domainErrors: target.domainErrors,
221
+ });
222
+
223
+ /**
224
+ * Start a multipart upload: `POST /mpu?pathname=…` with `x-mpu-action: create` and the
225
+ * `writeOptions.create` headers.
226
+ *
227
+ * @returns The handle: pathname, the store's key and upload id, `access` and every write option
228
+ * creation sent (cache lifetime in seconds), so parts and completion resend the same headers.
229
+ */
230
+ export const createMultipartUpload = Effect.fnUntraced(function* <
231
+ EA,
232
+ RA,
233
+ K extends BlobDomainErrorTag,
234
+ >(
235
+ target: MultipartTarget<EA, RA, K>,
236
+ pathname: BlobPathname,
237
+ options: WriteHeaderOptions,
238
+ ): Effect.fn.Return<MultipartUpload, BlobApiError<K> | EA, RA> {
239
+ const allowed = target.writeOptions.create;
240
+ const { key, uploadId } = yield* createRequest({
241
+ target,
242
+ pathname,
243
+ headers: writeHeaders(allowed, options),
244
+ });
245
+ return new MultipartUpload({
246
+ pathname,
247
+ key,
248
+ uploadId,
249
+ ...handleWriteOptions(allowed, options),
250
+ });
251
+ });
252
+
253
+ /**
254
+ * Upload one part: `POST /mpu?pathname=…` with `x-mpu-action: upload`, `x-mpu-key`
255
+ * (URI-encoded), `x-mpu-upload-id`, `x-mpu-part-number` and the `writeOptions.part` headers
256
+ * rebuilt from the handle. The body is sent as given; a `Stream` body is not retried.
257
+ */
258
+ export const uploadPart = Effect.fnUntraced(function* <
259
+ EA,
260
+ RA,
261
+ K extends BlobDomainErrorTag,
262
+ E = never,
263
+ R = never,
264
+ >(
265
+ target: MultipartTarget<EA, RA, K>,
266
+ upload: MultipartUpload,
267
+ partNumber: UploadedPart["partNumber"],
268
+ body: BlobBody<E, R>,
269
+ ): Effect.fn.Return<UploadedPart, BlobApiError<K> | EA | E, RA | R> {
270
+ const { etag } = yield* uploadPartRequest(
271
+ {
272
+ target,
273
+ pathname: upload.pathname,
274
+ headers: writeHeaders(target.writeOptions.part, uploadWriteOptions(upload)),
275
+ },
276
+ { key: upload.key, uploadId: upload.uploadId, partNumber, body },
277
+ );
278
+ return new UploadedPart({ etag, partNumber });
279
+ });
280
+
281
+ /**
282
+ * Assemble the parts into the blob: `POST /mpu?pathname=…` with `x-mpu-action: complete`, the
283
+ * `writeOptions.part` headers rebuilt from the handle, and the JSON array
284
+ * `[{"etag","partNumber"}, …]` in the order given.
285
+ */
286
+ export const completeMultipartUpload = <EA, RA, K extends BlobDomainErrorTag>(
287
+ target: MultipartTarget<EA, RA, K>,
288
+ upload: MultipartUpload,
289
+ parts: ReadonlyArray<CompletedPart>,
290
+ ) =>
291
+ completeRequest(
292
+ {
293
+ target,
294
+ pathname: upload.pathname,
295
+ headers: writeHeaders(target.writeOptions.part, uploadWriteOptions(upload)),
296
+ },
297
+ { key: upload.key, uploadId: upload.uploadId, parts },
298
+ );
299
+
300
+ /** Options of an automatic multipart upload. */
301
+ export type MultipartPutOptions = WriteHeaderOptions & {
302
+ /**
303
+ * Fail with `BlobUploadRejected` / `FileTooLarge` before any request when the body's known size
304
+ * is larger. Streams are not checked.
305
+ */
306
+ readonly maximumSizeInBytes?: number | undefined;
307
+
308
+ readonly onUploadProgress?: OnUploadProgress | undefined;
309
+
310
+ /** Parts sent at once; {@link maxConcurrentPartUploads} by default. */
311
+ readonly concurrency?: number | undefined;
312
+ };
313
+
314
+ /** A part to upload: its bytes, read when an upload slot is free. */
315
+ type PartSource = Effect.Effect<Uint8Array>;
316
+
317
+ /**
318
+ * Regroup a byte stream into parts of exactly `size` bytes, the last one smaller, copying each
319
+ * byte once into its part (the SDK cuts parts the same way, because R2 refuses uneven parts).
320
+ */
321
+ const regroupParts =
322
+ (size: number) =>
323
+ <E, R>(stream: Stream.Stream<Uint8Array, E, R>): Stream.Stream<Uint8Array, E, R> =>
324
+ Stream.mapAccum(
325
+ stream,
326
+ (): { readonly part: Uint8Array | undefined; readonly filled: number } => ({
327
+ part: undefined,
328
+ filled: 0,
329
+ }),
330
+ (state, chunk: Uint8Array) => {
331
+ let { part, filled } = state;
332
+ const full: Array<Uint8Array> = [];
333
+ let position = 0;
334
+ while (position < chunk.length) {
335
+ part ??= new Uint8Array(size);
336
+ const count = Math.min(size - filled, chunk.length - position);
337
+ part.set(chunk.subarray(position, position + count), filled);
338
+ filled += count;
339
+ position += count;
340
+ if (filled === size) {
341
+ full.push(part);
342
+ part = undefined;
343
+ filled = 0;
344
+ }
345
+ }
346
+ return [{ part, filled }, full] as const;
347
+ },
348
+ {
349
+ onHalt: ({ part, filled }) =>
350
+ part === undefined || filled === 0 ? [] : [part.slice(0, filled)],
351
+ },
352
+ );
353
+
354
+ const partRanges = (length: number, size: number): ReadonlyArray<readonly [number, number]> =>
355
+ Array.from({ length: Math.ceil(length / size) }, (_, index) => [
356
+ index * size,
357
+ Math.min(length, (index + 1) * size),
358
+ ]);
359
+
360
+ const textEncoder = new TextEncoder();
361
+
362
+ const isBlob = (body: unknown): body is globalThis.Blob =>
363
+ typeof globalThis.Blob !== "undefined" && body instanceof globalThis.Blob;
364
+
365
+ /**
366
+ * The body cut into parts, each one plain bytes so it can be retried and sent by browsers that
367
+ * cannot stream request bodies. In-memory bodies are cut into views without copying; a `Blob` is
368
+ * sliced and each slice read only when its upload starts; a stream is regrouped as it is read.
369
+ */
370
+ const bodyParts = <E, R>(body: BlobBody<E, R>, size: number): Stream.Stream<PartSource, E, R> => {
371
+ if (typeof body === "string" || body instanceof Uint8Array) {
372
+ const bytes = typeof body === "string" ? textEncoder.encode(body) : body;
373
+ return Stream.fromIterable(
374
+ partRanges(bytes.length, size).map(([start, end]) =>
375
+ Effect.succeed(bytes.subarray(start, end)),
376
+ ),
377
+ );
378
+ }
379
+ if (isBlob(body)) {
380
+ // A Blob that cannot be read is a defect, as in `bodyStream`.
381
+ return Stream.fromIterable(
382
+ partRanges(body.size, size).map(([start, end]) =>
383
+ Effect.map(
384
+ Effect.promise(() => body.slice(start, end).arrayBuffer()),
385
+ (buffer) => new Uint8Array(buffer),
386
+ ),
387
+ ),
388
+ );
389
+ }
390
+ return body.pipe(regroupParts(size), Stream.map(Effect.succeed));
391
+ };
392
+
393
+ const sumLoaded = (loadedByPart: ReadonlyMap<number, number>): number => {
394
+ let loaded = 0;
395
+ for (const partLoaded of loadedByPart.values()) loaded += partLoaded;
396
+ return loaded;
397
+ };
398
+
399
+ /** The SDK's aggregate event: bytes sent by every part, against the body's known size. */
400
+ const aggregateProgress = (
401
+ loadedByPart: ReadonlyMap<number, number>,
402
+ totalToLoad: number,
403
+ ): UploadProgress => {
404
+ const loaded = sumLoaded(loadedByPart);
405
+ return {
406
+ loaded,
407
+ total: totalToLoad || loaded,
408
+ percentage: totalToLoad > 0 ? Number(((loaded / totalToLoad) * 100).toFixed(2)) : 0,
409
+ };
410
+ };
411
+
412
+ /**
413
+ * Upload a body with a multipart upload, as the SDK's `uncontrolledMultipartUpload`: create it,
414
+ * send the body in {@link multipartPartSize} parts (the last one smaller) with bounded concurrency,
415
+ * then complete it with the parts in part-number order. Every request carries the
416
+ * `writeOptions.create` headers, as the SDK resends the `put` headers.
417
+ *
418
+ * - `maximumSizeInBytes` is checked against the known size before any request.
419
+ * - Memory stays bounded: a part's bytes are produced only when an upload slot is free, so at most
420
+ * `concurrency` parts (plus the parts cut from the stream chunk being read) are held at once.
421
+ * - The first failure, of a part or of the body stream, interrupts the parts in flight; the upload
422
+ * is then not completed.
423
+ * - An empty body uploads no part and completes with an empty part list, as the SDK does.
424
+ * - Progress: 0 % once the upload is created, then the bytes sent by all parts summed and
425
+ * throttled to one event per {@link multipartProgressInterval} (the SDK's numbers, rounded to two
426
+ * decimals). The event in which every byte has been acknowledged is held back, and
427
+ * `{ loaded, total: loaded, percentage: 100 }` is reported once the upload is completed instead.
428
+ *
429
+ * @returns The stored blob; fails with `BlobUploadRejected` for an oversized body, the requests'
430
+ * errors, or the body stream's error.
431
+ */
432
+ export const multipartPut = Effect.fnUntraced(function* <
433
+ EA,
434
+ RA,
435
+ K extends BlobDomainErrorTag,
436
+ E = never,
437
+ R = never,
438
+ >(
439
+ target: MultipartTarget<EA, RA, K>,
440
+ pathname: BlobPathname,
441
+ body: BlobBody<E, R>,
442
+ options: MultipartPutOptions,
443
+ ): Effect.fn.Return<PutBlobResult, BlobApiError<K> | BlobUploadRejected | EA | E, RA | R> {
444
+ const totalToLoad = bodyLength(body);
445
+ const maximum = options.maximumSizeInBytes;
446
+ if (maximum !== undefined && !Stream.isStream(body) && totalToLoad > maximum) {
447
+ return yield* new BlobUploadRejected({
448
+ reason: new FileTooLarge({
449
+ message: `Body size of ${totalToLoad} bytes exceeds the maximum allowed size of ${maximum} bytes`,
450
+ }),
451
+ });
452
+ }
453
+
454
+ const step: Step<EA, RA, K> = {
455
+ target,
456
+ pathname,
457
+ headers: writeHeaders(target.writeOptions.create, options),
458
+ };
459
+ const service = target.service;
460
+
461
+ const { key, uploadId } = yield* createRequest(step).pipe(
462
+ Effect.withSpan(`${service}.createMultipartUpload`),
463
+ );
464
+
465
+ const onUploadProgress = options.onUploadProgress;
466
+ const loadedByPart = yield* SynchronizedRef.make<ReadonlyMap<number, number>>(new Map());
467
+
468
+ const uploadParts = (
469
+ report: ((partNumber: number) => OnUploadProgress) | undefined,
470
+ ): Effect.Effect<Array<CompletedPart>, BlobApiError<K> | EA | E, RA | R> =>
471
+ bodyParts(body, multipartPartSize).pipe(
472
+ Stream.mapEffect(
473
+ (source, index) =>
474
+ Effect.gen(function* () {
475
+ const partNumber = index + 1;
476
+ const bytes = yield* source;
477
+ const { etag } = yield* uploadPartRequest(step, {
478
+ key,
479
+ uploadId,
480
+ partNumber,
481
+ body: bytes,
482
+ onUploadProgress: report?.(partNumber),
483
+ contentType: "application/octet-stream",
484
+ });
485
+ return { etag, partNumber };
486
+ }).pipe(
487
+ Effect.withSpan(`${service}.uploadPart`, { attributes: { partNumber: index + 1 } }),
488
+ ),
489
+ { concurrency: options.concurrency ?? maxConcurrentPartUploads(), unordered: true },
490
+ ),
491
+ Stream.runCollect,
492
+ );
493
+
494
+ const complete = (parts: ReadonlyArray<CompletedPart>) =>
495
+ completeRequest(step, {
496
+ key,
497
+ uploadId,
498
+ parts: [...parts].sort((a, b) => a.partNumber - b.partNumber),
499
+ partNumberFirst: true,
500
+ }).pipe(Effect.withSpan(`${service}.completeMultipartUpload`));
501
+
502
+ if (onUploadProgress === undefined) {
503
+ return yield* Effect.flatMap(uploadParts(undefined), complete);
504
+ }
505
+
506
+ yield* onUploadProgress({ loaded: 0, total: totalToLoad, percentage: 0 });
507
+ const reporter = yield* uploadProgressThrottle(onUploadProgress, multipartProgressInterval);
508
+ // Updating the sums and offering the event under one lock keeps events in sending order. The SDK
509
+ // also reports the aggregate in which every byte is acknowledged; it is left for the final event.
510
+ const partProgress =
511
+ (partNumber: number): OnUploadProgress =>
512
+ (progress) =>
513
+ SynchronizedRef.updateEffect(loadedByPart, (current) => {
514
+ const next = new Map(current).set(partNumber, progress.loaded);
515
+ const event = aggregateProgress(next, totalToLoad);
516
+ const acknowledged = totalToLoad > 0 && event.loaded >= totalToLoad;
517
+ return Effect.as(acknowledged ? Effect.void : reporter.report(event), next);
518
+ });
519
+ const blob = yield* uploadParts(partProgress).pipe(
520
+ Effect.flatMap(complete),
521
+ Effect.ensuring(reporter.stop),
522
+ );
523
+ const loaded = sumLoaded(yield* SynchronizedRef.get(loadedByPart));
524
+ yield* onUploadProgress({ loaded, total: loaded, percentage: 100 });
525
+ return blob;
526
+ });