@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,617 @@
1
+ import type { OidcTokenUnavailable, VercelOidc } from "@panorama-industries/effect-vercel-core";
2
+ import type { HttpClientError, HttpClientResponse } from "effect/http";
3
+
4
+ import {
5
+ ApiUnexpectedResponse,
6
+ VercelApiError,
7
+ vercelApiErrorFromHttpClientError,
8
+ vercelApiTransientRetry,
9
+ } from "@panorama-industries/effect-vercel-core";
10
+ import {
11
+ Clock,
12
+ Data,
13
+ Duration,
14
+ Effect,
15
+ Fiber,
16
+ Option,
17
+ Queue,
18
+ Random,
19
+ Redacted,
20
+ Ref,
21
+ Schema,
22
+ Stream,
23
+ } from "effect";
24
+ import { HttpBody, HttpClient, HttpClientRequest } from "effect/http";
25
+
26
+ import type { BlobConfig } from "../contracts/blob-config.ts";
27
+ import type {
28
+ BlobBody,
29
+ OnUploadProgress,
30
+ PresignedUrlPayload,
31
+ UploadProgress,
32
+ } from "../contracts/blob-domain.ts";
33
+ import type { BlobApiError, BlobDomainErrorTag } from "./blob-errors.ts";
34
+
35
+ import { BlobAuth } from "../contracts/blob-config.ts";
36
+ import { blobErrorFromHttpResponse, isRetryableBlobError } from "./blob-errors.ts";
37
+ import { storeIdFromReadWriteToken } from "./store-id.ts";
38
+
39
+ /**
40
+ * What every request of one component shares; build it once per layer, from `BlobConfig` with
41
+ * {@link blobRequestSettings} on the server, or from the client options in the browser.
42
+ */
43
+ export interface BlobRequestSettings {
44
+ readonly httpClient: HttpClient.HttpClient;
45
+
46
+ /** Blob API base URL; a call's `path` is appended to it after removing trailing slashes. */
47
+ readonly apiUrl: URL;
48
+
49
+ /** Sent as `x-api-version`. */
50
+ readonly apiVersion: string;
51
+
52
+ /** Retries after the first attempt; a call whose body is a `Stream` makes one attempt only. */
53
+ readonly retries: number;
54
+
55
+ /** Send `x-content-length` on every call, not only on calls with upload progress. */
56
+ readonly sendContentLength: boolean;
57
+
58
+ /** Sent with every call, after the protocol headers and before the call's own headers. */
59
+ readonly headers: Readonly<Record<string, string>>;
60
+
61
+ /**
62
+ * Whether `fetch` can stream request bodies; detected with {@link supportsRequestStreams} when
63
+ * omitted. When false, bodies are sent whole (streams are read into memory first) and upload
64
+ * progress reports only 0 % and 100 %.
65
+ */
66
+ readonly streamRequestBodies?: boolean | undefined;
67
+ }
68
+
69
+ /** Settings for server-side services, read from `BlobConfig`. */
70
+ export const blobRequestSettings = (
71
+ config: BlobConfig["Service"],
72
+ httpClient: HttpClient.HttpClient,
73
+ ): BlobRequestSettings => ({
74
+ httpClient,
75
+ apiUrl: config.apiUrl,
76
+ apiVersion: config.apiVersion,
77
+ retries: config.retries,
78
+ sendContentLength: config.sendContentLength,
79
+ headers: config.headers,
80
+ });
81
+
82
+ /** How one call authenticates, resolved once per call (before the first attempt). */
83
+ export type BlobRequestAuth = Data.TaggedEnum<{
84
+ /**
85
+ * `authorization: Bearer <token>`, for read-write, OIDC and client tokens. `storeId` goes into
86
+ * `x-vercel-blob-store-id` and the request id.
87
+ */
88
+ Bearer: { readonly token: Redacted.Redacted<string>; readonly storeId: string };
89
+ /**
90
+ * No `authorization`; the payload's `params`, `vercel-blob-delegation` and
91
+ * `vercel-blob-signature` are set on the query. Take `storeId` from the delegation token with
92
+ * `storeIdFromDelegationToken`.
93
+ */
94
+ Presigned: { readonly payload: PresignedUrlPayload; readonly storeId: string };
95
+ }>;
96
+
97
+ /** Constructors and matchers for {@link BlobRequestAuth}. */
98
+ export const BlobRequestAuth = Data.taggedEnum<BlobRequestAuth>();
99
+
100
+ /**
101
+ * The per-call auth of server-side services. `ReadWrite` uses its token. `Oidc` evaluates
102
+ * `VercelOidc.token` on every call; when it is unavailable and `BlobConfig.readWriteToken` is set,
103
+ * the call uses that token and its store id instead, as the SDK falls through to
104
+ * `BLOB_READ_WRITE_TOKEN`. Otherwise it fails with the `OidcTokenUnavailable`.
105
+ */
106
+ export const serverBlobAuth = (
107
+ config: BlobConfig["Service"],
108
+ oidc: VercelOidc["Service"],
109
+ ): Effect.Effect<BlobRequestAuth, OidcTokenUnavailable> => {
110
+ const fallback = Option.flatMap(config.readWriteToken, (token) =>
111
+ Option.map(storeIdFromReadWriteToken(Redacted.value(token)), (storeId) =>
112
+ BlobRequestAuth.Bearer({ token, storeId }),
113
+ ),
114
+ );
115
+ return BlobAuth.$match(config.auth, {
116
+ ReadWrite: ({ token, storeId }) => Effect.succeed(BlobRequestAuth.Bearer({ token, storeId })),
117
+ Oidc: ({ storeId }) =>
118
+ oidc.token.pipe(
119
+ Effect.map((token) => BlobRequestAuth.Bearer({ token, storeId })),
120
+ Effect.catchTag("OidcTokenUnavailable", (error) =>
121
+ Option.match(fallback, {
122
+ onNone: () => Effect.fail(error),
123
+ onSome: Effect.succeed,
124
+ }),
125
+ ),
126
+ ),
127
+ });
128
+ };
129
+
130
+ /** HTTP methods the Blob API uses. */
131
+ export type BlobRequestMethod = "GET" | "PUT" | "POST" | "DELETE";
132
+
133
+ /**
134
+ * One Blob API call for {@link BlobRequester} `call`.
135
+ *
136
+ * @template A - Decoded response body; `void` when `response` is omitted.
137
+ * @template E - Failure of a `Stream` body, reported as is (not as a network failure).
138
+ * @template R - Requirements of a `Stream` body.
139
+ * @template EA - Failure of `auth`, such as `OidcTokenUnavailable`.
140
+ * @template K - Domain errors the operation reports; other domain codes become `VercelApiError`.
141
+ */
142
+ export interface BlobApiCall<A, E, R, EA, RA, K extends BlobDomainErrorTag> {
143
+ /** Traced name, such as `BlobStore.put`; stored in `VercelApiError.operation`. */
144
+ readonly operation: string;
145
+
146
+ readonly method: BlobRequestMethod;
147
+
148
+ /**
149
+ * Path and query appended to the API URL exactly as the SDK appends it: `/?pathname=…` for put,
150
+ * `?url=…` (no slash) for head, `?limit…` for list, `/delete`, `/mpu?pathname=…`. Build queries
151
+ * with `URLSearchParams` so they are encoded like the SDK's.
152
+ */
153
+ readonly path: string;
154
+
155
+ readonly auth: Effect.Effect<BlobRequestAuth, EA, RA>;
156
+
157
+ /** Decodes the JSON response body (see `internal/wire.ts`); omit to ignore the body. */
158
+ readonly response?: Schema.Decoder<A> | undefined;
159
+
160
+ /** Sent last, so they override every other header (including `authorization`). */
161
+ readonly headers?: Readonly<Record<string, string>> | undefined;
162
+
163
+ /** Raw request body. Strings, bytes and `Blob`s are resent on retries; streams are not. */
164
+ readonly body?: BlobBody<E, R> | undefined;
165
+
166
+ /**
167
+ * A JSON request body, sent as `JSON.stringify(json)` with `content-type: application/json`
168
+ * (before `headers`). Takes precedence over `body`.
169
+ */
170
+ readonly json?: unknown;
171
+
172
+ /** What `BlobNotFound.locator` reports for `not_found`; defaults to `path`. */
173
+ readonly locator?: string | undefined;
174
+
175
+ /** Domain errors the operation surfaces; all by default. See `blobErrorFromResponse`. */
176
+ readonly domainErrors?: ReadonlyArray<K> | undefined;
177
+
178
+ /**
179
+ * Upload progress, reported as the SDK does: `{ loaded: 0, total, percentage: 0 }` before the
180
+ * first attempt, then once per 64 KiB chunk sent (never 100 % while waiting for the response),
181
+ * then `{ loaded, total: loaded, percentage: 100 }` once a successful response arrived. Also sends
182
+ * `x-content-length`. Where `fetch` cannot stream request bodies, only the first and last events
183
+ * are reported.
184
+ */
185
+ readonly onUploadProgress?: OnUploadProgress | undefined;
186
+
187
+ /**
188
+ * Minimum time between chunk events (leading and trailing edge, like `throttleit`); the SDK uses
189
+ * 100 ms for `put`. The first and last events are never delayed. Unthrottled when omitted.
190
+ */
191
+ readonly progressInterval?: Duration.Input | undefined;
192
+ }
193
+
194
+ /** A GET on a blob's own URL, for `get`: no Blob API headers, no retries. */
195
+ export interface BlobFetchRequest {
196
+ /** Traced name, such as `BlobStore.get`; stored in `VercelApiError.operation`. */
197
+ readonly operation: string;
198
+
199
+ readonly url: URL;
200
+
201
+ /** Sent as `authorization: Bearer <token>`. */
202
+ readonly token: Redacted.Redacted<string>;
203
+
204
+ /** Sent after `authorization`, so they override it; put `if-none-match` here. */
205
+ readonly headers?: Readonly<Record<string, string>> | undefined;
206
+ }
207
+
208
+ /** Executes Blob API calls with the SDK's headers, retries, error mapping and progress. */
209
+ export interface BlobRequester {
210
+ /**
211
+ * Send one API call, retrying it like the SDK's `requestApi`, and decode the response.
212
+ *
213
+ * Every attempt carries `x-api-blob-request-id` (`<storeId>:<epoch ms>:<random hex>`, the same
214
+ * for all attempts), `x-api-blob-request-attempt` (`0`, `1`, …), `x-api-version`,
215
+ * `x-vercel-blob-store-id`, `authorization` for bearer auth, `x-content-length` with progress or
216
+ * `sendContentLength`, then the settings' headers, then the call's headers. Network failures and
217
+ * retryable error codes (`isRetryableBlobError`) are retried up to `retries` times with
218
+ * exponential backoff from 1 second; a `Stream` body is sent once.
219
+ *
220
+ * @returns The decoded body; fails with `VercelApiError` (`ApiNetworkFailure` for transport
221
+ * failures, `ApiUnexpectedResponse` for a body `response` cannot decode), a domain error among
222
+ * `domainErrors`, the auth effect's error, or the body stream's error.
223
+ */
224
+ readonly call: <
225
+ A = void,
226
+ E = never,
227
+ R = never,
228
+ EA = never,
229
+ RA = never,
230
+ K extends BlobDomainErrorTag = BlobDomainErrorTag,
231
+ >(
232
+ call: BlobApiCall<A, E, R, EA, RA, K>,
233
+ ) => Effect.Effect<A, BlobApiError<K> | E | EA, R | RA>;
234
+
235
+ /**
236
+ * Send a GET to a blob URL and return the response whatever its status; the caller maps 304,
237
+ * 404 and other statuses. Transport failures are `VercelApiError` / `ApiNetworkFailure`.
238
+ */
239
+ readonly fetch: (
240
+ request: BlobFetchRequest,
241
+ ) => Effect.Effect<HttpClientResponse.HttpClientResponse, VercelApiError>;
242
+ }
243
+
244
+ /** Size of the chunks a progress-tracked body is sent in, as the SDK's `CHUNK_SIZE`. */
245
+ export const progressChunkSize = 64 * 1024;
246
+
247
+ const concatBytes = (a: Uint8Array, b: Uint8Array): Uint8Array => {
248
+ const result = new Uint8Array(a.length + b.length);
249
+ result.set(a);
250
+ result.set(b, a.length);
251
+ return result;
252
+ };
253
+
254
+ /**
255
+ * Regroup a byte stream into chunks of exactly `size` bytes, the last one possibly smaller, like
256
+ * the SDK's `createChunkTransformStream`. Empty input chunks produce nothing.
257
+ */
258
+ export const chunkBytes =
259
+ (size: number) =>
260
+ <E, R>(stream: Stream.Stream<Uint8Array, E, R>): Stream.Stream<Uint8Array, E, R> =>
261
+ Stream.mapAccum(
262
+ stream,
263
+ () => new Uint8Array(0),
264
+ (pending: Uint8Array, chunk: Uint8Array) => {
265
+ let buffer = pending.length === 0 ? chunk : concatBytes(pending, chunk);
266
+ const out: Array<Uint8Array> = [];
267
+ while (buffer.length >= size) {
268
+ out.push(buffer.slice(0, size));
269
+ buffer = buffer.subarray(size);
270
+ }
271
+ return [buffer, out] as const;
272
+ },
273
+ { onHalt: (buffer) => (buffer.length > 0 ? [buffer.slice()] : []) },
274
+ );
275
+
276
+ const textEncoder = new TextEncoder();
277
+
278
+ const isBlob = (body: unknown): body is globalThis.Blob =>
279
+ typeof globalThis.Blob !== "undefined" && body instanceof globalThis.Blob;
280
+
281
+ const isStreamBody = <E, R>(body: BlobBody<E, R>): body is Stream.Stream<Uint8Array, E, R> =>
282
+ Stream.isStream(body);
283
+
284
+ /**
285
+ * Bytes a body will send when known up front, as the SDK's `computeBodyLength`: UTF-8 length of a
286
+ * string, byte length, `Blob` size; 0 for a stream.
287
+ */
288
+ export const bodyLength = <E, R>(body: BlobBody<E, R>): number => {
289
+ if (typeof body === "string") return textEncoder.encode(body).byteLength;
290
+ if (body instanceof Uint8Array) return body.byteLength;
291
+ if (isBlob(body)) return body.size;
292
+ return 0;
293
+ };
294
+
295
+ /** A body as a stream of bytes. A `Blob` that cannot be read is a defect. */
296
+ export const bodyStream = <E, R>(body: BlobBody<E, R>): Stream.Stream<Uint8Array, E, R> => {
297
+ if (typeof body === "string") return Stream.succeed(textEncoder.encode(body));
298
+ if (body instanceof Uint8Array) return Stream.succeed(body);
299
+ if (isBlob(body)) {
300
+ return Stream.fromReadableStream({
301
+ evaluate: () => body.stream(),
302
+ onError: (cause) => cause,
303
+ }).pipe(Stream.orDie);
304
+ }
305
+ return body;
306
+ };
307
+
308
+ const isNodeProcess = (): boolean => {
309
+ if (typeof navigator !== "undefined" && navigator.product === "ReactNative") return true;
310
+ const process = (
311
+ globalThis as {
312
+ readonly process?: { readonly type?: string; readonly versions?: { readonly node?: string } };
313
+ }
314
+ ).process;
315
+ if (process === undefined) return false;
316
+ if (process.type === "renderer" || process.type === "worker") return false;
317
+ return process.versions?.node !== undefined;
318
+ };
319
+
320
+ /**
321
+ * Whether `fetch` can stream a request body here, detected like the SDK's
322
+ * `supportsRequestStreams`: always on Node; never against `http://localhost` (no HTTP/2); otherwise
323
+ * when `Request` reads the `duplex` option and does not type a stream body (Chromium, Edge
324
+ * runtimes, not Safari or Firefox).
325
+ */
326
+ export const supportsRequestStreams = (apiUrl: string): boolean => {
327
+ if (isNodeProcess()) return true;
328
+ if (apiUrl.startsWith("http://localhost")) return false;
329
+ try {
330
+ let duplexAccessed = false;
331
+ const init = {
332
+ body: new ReadableStream(),
333
+ method: "POST",
334
+ get duplex() {
335
+ duplexAccessed = true;
336
+ return "half";
337
+ },
338
+ };
339
+ const hasContentType = new Request(apiUrl, init as RequestInit).headers.has("Content-Type");
340
+ return duplexAccessed && !hasContentType;
341
+ } catch {
342
+ return false;
343
+ }
344
+ };
345
+
346
+ /** Delivers progress events, possibly throttled; see {@link uploadProgressThrottle}. */
347
+ export interface UploadProgressReporter {
348
+ readonly report: (progress: UploadProgress) => Effect.Effect<void>;
349
+
350
+ /** Drop any event still waiting for its interval and stop reporting. */
351
+ readonly stop: Effect.Effect<void>;
352
+ }
353
+
354
+ /**
355
+ * Throttle progress events like `throttleit`, which the SDK wraps `onUploadProgress` in: the first
356
+ * event after a quiet interval is reported at once, later ones at most once per `interval`, and the
357
+ * latest event of an interval is reported when it ends (earlier ones are dropped).
358
+ *
359
+ * The interval starts now, as if an event had just been reported (callers report 0 % first). Call
360
+ * `stop` before the final 100 % event, which callers report directly so it is never delayed.
361
+ * Without an interval (or a zero one), events are reported immediately.
362
+ */
363
+ export const uploadProgressThrottle = Effect.fnUntraced(function* (
364
+ onUploadProgress: OnUploadProgress,
365
+ interval: Duration.Input | undefined,
366
+ ): Effect.fn.Return<UploadProgressReporter> {
367
+ if (interval === undefined || Duration.isZero(Duration.fromInputUnsafe(interval))) {
368
+ return { report: onUploadProgress, stop: Effect.void };
369
+ }
370
+ const latest = yield* Queue.sliding<UploadProgress>(1);
371
+ const fiber = yield* Effect.forkChild(
372
+ Effect.sleep(interval).pipe(
373
+ Effect.andThen(
374
+ Effect.forever(
375
+ Queue.take(latest).pipe(
376
+ Effect.flatMap(onUploadProgress),
377
+ Effect.andThen(Effect.sleep(interval)),
378
+ ),
379
+ ),
380
+ ),
381
+ ),
382
+ );
383
+ return {
384
+ report: (progress) => Effect.asVoid(Queue.offer(latest, progress)),
385
+ stop: Fiber.interrupt(fiber),
386
+ };
387
+ });
388
+
389
+ const unexpectedResponse = (operation: string, status: number, message: string) =>
390
+ new VercelApiError({
391
+ operation,
392
+ errorCode: Option.none(),
393
+ reason: new ApiUnexpectedResponse({ message, status }),
394
+ });
395
+
396
+ const withoutTracePropagation = Effect.provideService(HttpClient.TracerPropagationEnabled, false);
397
+
398
+ /**
399
+ * Build the {@link BlobRequester} that every service uses for Blob API calls.
400
+ *
401
+ * Requests carry no `traceparent`/`b3` headers (the SDK sends none, and browsers would need CORS
402
+ * approval for them); the HTTP client still records a span per attempt.
403
+ */
404
+ export const blobRequester = (settings: BlobRequestSettings): BlobRequester => {
405
+ const baseUrl = settings.apiUrl.href.replace(/\/+$/, "");
406
+ const streamsSupported =
407
+ settings.streamRequestBodies ?? supportsRequestStreams(settings.apiUrl.href);
408
+ const client = settings.httpClient;
409
+
410
+ const requestUrl = (path: string, auth: BlobRequestAuth): string => {
411
+ const url = `${baseUrl}${path}`;
412
+ return BlobRequestAuth.$match(auth, {
413
+ Bearer: () => url,
414
+ Presigned: ({ payload }) => {
415
+ const presigned = new URL(url);
416
+ for (const [key, value] of Object.entries(payload.params)) {
417
+ presigned.searchParams.set(key, value);
418
+ }
419
+ presigned.searchParams.set("vercel-blob-delegation", payload.delegationToken);
420
+ presigned.searchParams.set("vercel-blob-signature", payload.signature);
421
+ return presigned.toString();
422
+ },
423
+ });
424
+ };
425
+
426
+ const call = Effect.fnUntraced(function* <
427
+ A,
428
+ E,
429
+ R,
430
+ EA,
431
+ RA,
432
+ K extends BlobDomainErrorTag = BlobDomainErrorTag,
433
+ >(call: BlobApiCall<A, E, R, EA, RA, K>): Effect.fn.Return<A, BlobApiError<K> | E | EA, R | RA> {
434
+ const { operation } = call;
435
+ const auth = yield* call.auth;
436
+ const url = requestUrl(call.path, auth);
437
+ const now = yield* Clock.currentTimeMillis;
438
+ const random = yield* Random.next;
439
+ const requestId = `${auth.storeId}:${now}:${random.toString(16).slice(2)}`;
440
+
441
+ const body: BlobBody<E, R> | undefined =
442
+ call.json === undefined ? call.body : JSON.stringify(call.json);
443
+ const onUploadProgress = call.onUploadProgress;
444
+ const sendBodyLength = onUploadProgress !== undefined || settings.sendContentLength;
445
+ const totalLength = body !== undefined && sendBodyLength ? bodyLength(body) : 0;
446
+ const streamBody = body !== undefined && isStreamBody(body);
447
+
448
+ const context = yield* Effect.context<R>();
449
+ const bodyFailure = yield* Ref.make(Option.none<E>());
450
+ const totalLoaded = yield* Ref.make(0);
451
+
452
+ const userStream = (stream: Stream.Stream<Uint8Array, E, R>): Stream.Stream<Uint8Array> =>
453
+ stream.pipe(
454
+ Stream.tapError((error) => Ref.set(bodyFailure, Option.some(error))),
455
+ Stream.provideContext(context),
456
+ ) as Stream.Stream<Uint8Array>;
457
+
458
+ const headersFor = (attempt: number): Record<string, string> => ({
459
+ "x-api-blob-request-id": requestId,
460
+ "x-vercel-blob-store-id": auth.storeId,
461
+ "x-api-blob-request-attempt": String(attempt),
462
+ "x-api-version": settings.apiVersion,
463
+ ...(sendBodyLength ? { "x-content-length": String(totalLength) } : {}),
464
+ ...BlobRequestAuth.$match(auth, {
465
+ Bearer: ({ token }) => ({ authorization: `Bearer ${Redacted.value(token)}` }),
466
+ Presigned: () => ({}),
467
+ }),
468
+ ...settings.headers,
469
+ ...(call.json === undefined ? {} : { "content-type": "application/json" }),
470
+ ...call.headers,
471
+ });
472
+
473
+ // A streamed body is sent untyped, like the SDK's `ReadableStream` bodies.
474
+ const streamedBody =
475
+ (stream: Stream.Stream<Uint8Array>) => (request: HttpClientRequest.HttpClientRequest) =>
476
+ request.pipe(
477
+ HttpClientRequest.setBody(HttpBody.stream(stream)),
478
+ HttpClientRequest.removeHeader("content-type"),
479
+ );
480
+
481
+ const withBody = (
482
+ reporter: UploadProgressReporter | undefined,
483
+ ): Effect.Effect<
484
+ (request: HttpClientRequest.HttpClientRequest) => HttpClientRequest.HttpClientRequest,
485
+ E
486
+ > =>
487
+ Effect.gen(function* () {
488
+ if (body === undefined) {
489
+ return (request) => request;
490
+ }
491
+ if (!streamsSupported) {
492
+ const bytes = streamBody
493
+ ? (yield* Stream.runCollect(Stream.provideContext(body, context))).reduce(
494
+ concatBytes,
495
+ new Uint8Array(0),
496
+ )
497
+ : undefined;
498
+ yield* Ref.set(totalLoaded, bytes === undefined ? totalLength : bytes.length);
499
+ return HttpClientRequest.setBody(HttpBody.raw(bytes ?? body));
500
+ }
501
+ if (reporter !== undefined) {
502
+ const loaded = yield* Ref.make(0);
503
+ const source = streamBody
504
+ ? userStream(body)
505
+ : (bodyStream(body) as Stream.Stream<Uint8Array>);
506
+ const tracked = source.pipe(
507
+ chunkBytes(progressChunkSize),
508
+ Stream.tap((chunk) =>
509
+ Effect.gen(function* () {
510
+ const sent = yield* Ref.updateAndGet(loaded, (n) => n + chunk.length);
511
+ yield* Ref.set(totalLoaded, sent);
512
+ const total = totalLength !== 0 ? totalLength : sent;
513
+ const percentage = totalLength > 0 ? Number(((sent / total) * 100).toFixed(2)) : 0;
514
+ if (percentage === 100 && totalLength > 0) return;
515
+ yield* reporter.report({ loaded: sent, total, percentage });
516
+ }),
517
+ ),
518
+ );
519
+ return streamedBody(tracked);
520
+ }
521
+ if (streamBody) {
522
+ return streamedBody(userStream(body));
523
+ }
524
+ return HttpClientRequest.setBody(HttpBody.raw(body));
525
+ });
526
+
527
+ const failTransport = (error: HttpClientError.HttpClientError) =>
528
+ Effect.flatMap(
529
+ Ref.get(bodyFailure),
530
+ Option.match({
531
+ onNone: () => vercelApiErrorFromHttpClientError(error, { operation }),
532
+ onSome: (failure): Effect.Effect<never, E | VercelApiError> => Effect.fail(failure),
533
+ }),
534
+ );
535
+
536
+ const attempts = yield* Ref.make(0);
537
+ const attempt = (reporter: UploadProgressReporter | undefined) =>
538
+ Effect.gen(function* () {
539
+ const index = yield* Ref.getAndUpdate(attempts, (n) => n + 1);
540
+ const setBody = yield* withBody(reporter);
541
+ const request = HttpClientRequest.make(call.method)(url).pipe(
542
+ setBody,
543
+ HttpClientRequest.setHeaders(headersFor(index)),
544
+ );
545
+ const response = yield* client
546
+ .execute(request)
547
+ .pipe(Effect.catchTag("HttpClientError", failTransport));
548
+ if (response.status >= 200 && response.status < 300) {
549
+ return response;
550
+ }
551
+ return yield* blobErrorFromHttpResponse(response, {
552
+ operation,
553
+ locator: call.locator ?? call.path,
554
+ domainErrors: call.domainErrors,
555
+ });
556
+ }).pipe(
557
+ Effect.retry(
558
+ vercelApiTransientRetry({
559
+ maxAttempts: streamBody ? 1 : settings.retries + 1,
560
+ baseDelay: "1 second",
561
+ isRetryable: isRetryableBlobError,
562
+ }),
563
+ ),
564
+ );
565
+
566
+ const response =
567
+ onUploadProgress === undefined
568
+ ? yield* attempt(undefined)
569
+ : yield* Effect.gen(function* () {
570
+ yield* onUploadProgress({ loaded: 0, total: totalLength, percentage: 0 });
571
+ const reporter = yield* uploadProgressThrottle(onUploadProgress, call.progressInterval);
572
+ const response = yield* attempt(reporter).pipe(Effect.ensuring(reporter.stop));
573
+ const loaded = yield* Ref.get(totalLoaded);
574
+ yield* onUploadProgress({ loaded, total: loaded, percentage: 100 });
575
+ return response;
576
+ });
577
+
578
+ if (call.response === undefined) {
579
+ yield* Effect.ignore(response.arrayBuffer);
580
+ return undefined as A;
581
+ }
582
+ const json = yield* response.json.pipe(
583
+ Effect.mapError((error) => unexpectedResponse(operation, response.status, error.message)),
584
+ );
585
+ return yield* Schema.decodeUnknownEffect(call.response)(json).pipe(
586
+ Effect.mapError((error) =>
587
+ unexpectedResponse(
588
+ operation,
589
+ response.status,
590
+ `Unexpected response body: ${error.message}`,
591
+ ),
592
+ ),
593
+ );
594
+ });
595
+
596
+ const fetch = (request: BlobFetchRequest) =>
597
+ client
598
+ .execute(
599
+ HttpClientRequest.get(request.url).pipe(
600
+ HttpClientRequest.setHeaders({
601
+ authorization: `Bearer ${Redacted.value(request.token)}`,
602
+ ...request.headers,
603
+ }),
604
+ ),
605
+ )
606
+ .pipe(
607
+ Effect.catchTag("HttpClientError", (error) =>
608
+ vercelApiErrorFromHttpClientError(error, { operation: request.operation }),
609
+ ),
610
+ withoutTracePropagation,
611
+ );
612
+
613
+ return {
614
+ call: (request) => withoutTracePropagation(call(request)),
615
+ fetch,
616
+ };
617
+ };