@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,528 @@
1
+ import type { OidcTokenUnavailable } from "@panorama-industries/effect-vercel-core";
2
+
3
+ import {
4
+ ApiNetworkFailure,
5
+ VercelApiError,
6
+ VercelOidc,
7
+ vercelApiErrorFromStatus,
8
+ } from "@panorama-industries/effect-vercel-core";
9
+ import { Clock, DateTime, Effect, Layer, Option, Stream } from "effect";
10
+ import { Headers, HttpClient } from "effect/http";
11
+
12
+ import type {
13
+ BlobAccess,
14
+ BlobBody,
15
+ BlobLocator,
16
+ BlobPathname,
17
+ CreateBlobOptions,
18
+ ETag,
19
+ MultipartUpload,
20
+ PartNumber,
21
+ StoreId,
22
+ UploadedPart,
23
+ } from "../contracts/blob-domain.ts";
24
+ import type {
25
+ BlobWriteError,
26
+ CreateMultipartUploadOptions,
27
+ GetOptions,
28
+ ImageFormat,
29
+ ImageOptimization,
30
+ ListOptions,
31
+ PutImageOptions,
32
+ PutOptions,
33
+ } from "../contracts/blob-store.ts";
34
+ import type { BlobRequester } from "../internal/blob-request.ts";
35
+
36
+ import { BlobConfig } from "../contracts/blob-config.ts";
37
+ import { FetchedBlob, GetBlobResult, PutBlobResult } from "../contracts/blob-domain.ts";
38
+ import {
39
+ BlobNotFound,
40
+ BlobUploadRejected,
41
+ ContentTypeNotAllowed,
42
+ } from "../contracts/blob-errors.ts";
43
+ import { BlobStore } from "../contracts/blob-store.ts";
44
+ import {
45
+ BlobRequestAuth,
46
+ blobRequester,
47
+ blobRequestSettings,
48
+ serverBlobAuth,
49
+ } from "../internal/blob-request.ts";
50
+ import * as Multipart from "../internal/multipart.ts";
51
+ import { blobUrl, downloadUrl, isAllowedBlobUrl, isUrl } from "../internal/urls.ts";
52
+ import {
53
+ CreateFolderResponse,
54
+ HeadBlobResponse,
55
+ ListResponse,
56
+ PutBlobResponse,
57
+ } from "../internal/wire.ts";
58
+ import { allowedWriteOptions, copyWriteHeaders, writeHeaders } from "../internal/write-headers.ts";
59
+
60
+ /*
61
+ * Domain errors each operation reports, matching the error unions of its contract. Every other
62
+ * domain code an error body carries is reported as `VercelApiError` for its status.
63
+ */
64
+ const requestErrors = ["BlobStoreUnavailable", "BlobOidcEnvironmentNotAllowed"] as const;
65
+ const readErrors = [...requestErrors, "BlobNotFound"] as const;
66
+ const deleteErrors = [...requestErrors, "BlobPreconditionFailed"] as const;
67
+ const writeErrors = [...requestErrors, "BlobPreconditionFailed", "BlobUploadRejected"] as const;
68
+ const copyErrors = [...writeErrors, "BlobNotFound"] as const;
69
+
70
+ /** `put` progress events are throttled like the SDK's `throttle(onUploadProgress, 100)`. */
71
+ const putProgressInterval = "100 millis";
72
+
73
+ /** The SDK's `optimizeImageFormatToMimeType`: `format` is sent as the output mime type. */
74
+ const imageFormatMimeType: Record<ImageFormat, string> = {
75
+ jpeg: "image/jpeg",
76
+ png: "image/png",
77
+ webp: "image/webp",
78
+ avif: "image/avif",
79
+ };
80
+
81
+ const defaultImageQuality = 75;
82
+
83
+ /** The SDK's `addOptimizeImageParams`; the branded option types already hold its validation. */
84
+ const addImageParams = (params: URLSearchParams, optimization: ImageOptimization): void => {
85
+ params.set("width", String(optimization.width));
86
+ params.set("quality", String(optimization.quality ?? defaultImageQuality));
87
+ if (optimization.format !== undefined) {
88
+ params.set("format", imageFormatMimeType[optimization.format]);
89
+ }
90
+ };
91
+
92
+ const isBlob = (body: unknown): body is globalThis.Blob =>
93
+ typeof globalThis.Blob !== "undefined" && body instanceof globalThis.Blob;
94
+
95
+ /**
96
+ * The SDK's `validateOptimizeImageSourceContentType`: a known media type that is neither an image
97
+ * nor `application/octet-stream` cannot be optimized. Unknown types are left to the server.
98
+ */
99
+ const nonImageMediaType = (mediaType: string): boolean =>
100
+ mediaType !== "" && mediaType !== "application/octet-stream" && !mediaType.startsWith("image/");
101
+
102
+ const locatorString = (locator: BlobLocator): string =>
103
+ typeof locator === "string" ? locator : locator.href;
104
+
105
+ /** A defect carrying the SDK's `BlobError` message, for input the SDK rejects by throwing. */
106
+ const invalidInput = (message: string) => Effect.die(new Error(`Vercel Blob: ${message}`));
107
+
108
+ const notBlobStoreUrl =
109
+ "Invalid URL: the URL does not point to a Vercel Blob store. Use a pathname instead, see https://vercel.com/docs/vercel-blob";
110
+
111
+ const header = (headers: Headers.Headers, name: string): string | undefined =>
112
+ Option.getOrUndefined(Headers.get(headers, name));
113
+
114
+ /**
115
+ * Multipart operations of {@link BlobStoreLive}, over the shared engine in `internal/multipart.ts`
116
+ * with the server's write headers and domain errors.
117
+ */
118
+ interface MultipartOperations {
119
+ /** `put(..., { multipart: true })`: automatic multipart over the body stream. */
120
+ readonly put: <E, R>(
121
+ pathname: BlobPathname,
122
+ body: BlobBody<E, R>,
123
+ options: PutOptions,
124
+ ) => Effect.Effect<PutBlobResult, BlobWriteError | E, R>;
125
+ readonly createMultipartUpload: BlobStore["Service"]["createMultipartUpload"];
126
+ readonly uploadPart: BlobStore["Service"]["uploadPart"];
127
+ readonly completeMultipartUpload: BlobStore["Service"]["completeMultipartUpload"];
128
+ }
129
+
130
+ const multipartOperations = (dependencies: {
131
+ readonly requester: BlobRequester;
132
+ readonly auth: Effect.Effect<BlobRequestAuth, OidcTokenUnavailable>;
133
+ }): MultipartOperations => {
134
+ const target: Multipart.MultipartTarget<
135
+ OidcTokenUnavailable,
136
+ never,
137
+ (typeof writeErrors)[number]
138
+ > = {
139
+ requester: dependencies.requester,
140
+ auth: dependencies.auth,
141
+ service: "BlobStore",
142
+ domainErrors: writeErrors,
143
+ writeOptions: Multipart.serverMultipartWriteOptions,
144
+ };
145
+ return {
146
+ put: (pathname, body, options) => Multipart.multipartPut(target, pathname, body, options),
147
+ createMultipartUpload: Effect.fn("BlobStore.createMultipartUpload")(function* (
148
+ pathname: BlobPathname,
149
+ options: CreateMultipartUploadOptions,
150
+ ) {
151
+ return yield* Multipart.createMultipartUpload(target, pathname, options);
152
+ }),
153
+ uploadPart: Effect.fn("BlobStore.uploadPart")(function* <E = never, R = never>(
154
+ upload: MultipartUpload,
155
+ partNumber: PartNumber,
156
+ body: BlobBody<E, R>,
157
+ ): Effect.fn.Return<UploadedPart, BlobWriteError | E, R> {
158
+ return yield* Multipart.uploadPart(target, upload, partNumber, body);
159
+ }),
160
+ completeMultipartUpload: Effect.fn("BlobStore.completeMultipartUpload")(function* (
161
+ upload: MultipartUpload,
162
+ parts: ReadonlyArray<UploadedPart>,
163
+ ) {
164
+ return yield* Multipart.completeMultipartUpload(target, upload, parts);
165
+ }),
166
+ };
167
+ };
168
+
169
+ /**
170
+ * Implements {@link BlobStore} over the Blob API with the SDK's wire protocol, retries and error
171
+ * mapping. Requires `BlobConfig`, an `HttpClient`, and `VercelOidc` (evaluated per request when
172
+ * `BlobConfig.auth` is OIDC).
173
+ *
174
+ * `get` reads from the blob's host. A URL locator must pass the SDK's `isAllowedBlobUrl` check (a
175
+ * `*.blob.vercel-storage.com` host, or the origin of an overridden API URL); any other URL, or a
176
+ * string that starts with `http(s)://` but does not parse, is a defect, as are an empty `get`
177
+ * locator and a `putImage` source `URL` that is not http(s). These are programming errors the SDK
178
+ * throws `BlobError` for.
179
+ */
180
+ export const BlobStoreLive: Layer.Layer<
181
+ BlobStore,
182
+ never,
183
+ BlobConfig | HttpClient.HttpClient | VercelOidc
184
+ > = Layer.effect(
185
+ BlobStore,
186
+ Effect.gen(function* () {
187
+ const config = yield* BlobConfig;
188
+ const httpClient = yield* HttpClient.HttpClient;
189
+ const oidc = yield* VercelOidc;
190
+ const requester = blobRequester(blobRequestSettings(config, httpClient));
191
+ const auth = serverBlobAuth(config, oidc);
192
+ const multipart = multipartOperations({ requester, auth });
193
+
194
+ const put = Effect.fn("BlobStore.put")(function* <E = never, R = never>(
195
+ pathname: BlobPathname,
196
+ body: BlobBody<E, R>,
197
+ options: PutOptions,
198
+ ): Effect.fn.Return<PutBlobResult, BlobWriteError | E, R> {
199
+ if (options.multipart === true) {
200
+ return yield* multipart.put(pathname, body, options);
201
+ }
202
+ const query = new URLSearchParams({ pathname });
203
+ return yield* requester.call({
204
+ operation: "BlobStore.put",
205
+ method: "PUT",
206
+ path: `/?${query.toString()}`,
207
+ auth,
208
+ response: PutBlobResponse,
209
+ headers: writeHeaders(allowedWriteOptions.put, options),
210
+ body,
211
+ locator: pathname,
212
+ domainErrors: writeErrors,
213
+ onUploadProgress: options.onUploadProgress,
214
+ progressInterval: putProgressInterval,
215
+ });
216
+ });
217
+
218
+ const putImage = Effect.fn("BlobStore.putImage")(function* <E = never, R = never>(
219
+ pathname: BlobPathname,
220
+ source: BlobBody<E, R> | URL,
221
+ options: PutImageOptions,
222
+ ): Effect.fn.Return<PutBlobResult, BlobWriteError | E, R> {
223
+ const headers = writeHeaders(allowedWriteOptions.putImage, options);
224
+ if (source instanceof URL) {
225
+ if (source.protocol !== "http:" && source.protocol !== "https:") {
226
+ return yield* invalidInput("the source URL must use the http(s) protocol");
227
+ }
228
+ const query = new URLSearchParams({ pathname, url: source.toString() });
229
+ addImageParams(query, options.optimizeImage);
230
+ return yield* requester.call({
231
+ operation: "BlobStore.putImage",
232
+ method: "POST",
233
+ path: `/put-from-url?${query.toString()}`,
234
+ auth,
235
+ response: PutBlobResponse,
236
+ headers,
237
+ locator: pathname,
238
+ domainErrors: writeErrors,
239
+ onUploadProgress: options.onUploadProgress,
240
+ });
241
+ }
242
+ if (isBlob(source) && nonImageMediaType(source.type)) {
243
+ return yield* new BlobUploadRejected({
244
+ reason: new ContentTypeNotAllowed({
245
+ message: `optimizeImage requires an image body, but the content type is "${source.type}"`,
246
+ }),
247
+ });
248
+ }
249
+ const query = new URLSearchParams({ pathname });
250
+ addImageParams(query, options.optimizeImage);
251
+ return yield* requester.call({
252
+ operation: "BlobStore.putImage",
253
+ method: "POST",
254
+ path: `/put-optimized?${query.toString()}`,
255
+ auth,
256
+ response: PutBlobResponse,
257
+ headers,
258
+ body: source,
259
+ locator: pathname,
260
+ domainErrors: writeErrors,
261
+ onUploadProgress: options.onUploadProgress,
262
+ });
263
+ });
264
+
265
+ const head = Effect.fn("BlobStore.head")(function* (blob: BlobLocator) {
266
+ const locator = locatorString(blob);
267
+ const query = new URLSearchParams({ url: locator });
268
+ return yield* requester.call({
269
+ operation: "BlobStore.head",
270
+ method: "GET",
271
+ path: `?${query.toString()}`,
272
+ auth,
273
+ response: HeadBlobResponse,
274
+ locator,
275
+ domainErrors: readErrors,
276
+ });
277
+ });
278
+
279
+ /** Where `get` reads a locator, and the pathname it reports, as the SDK resolves them. */
280
+ const resolveBlobUrl = (
281
+ blob: BlobLocator,
282
+ storeId: StoreId,
283
+ access: BlobAccess,
284
+ ): Effect.Effect<{ readonly url: URL; readonly pathname: string }> => {
285
+ if (typeof blob === "string" && !isUrl(blob)) {
286
+ return Effect.succeed({
287
+ url: blobUrl(config.blobBaseUrl(storeId, access), blob),
288
+ pathname: blob,
289
+ });
290
+ }
291
+ return Effect.suspend(() => {
292
+ let url: URL;
293
+ try {
294
+ url = typeof blob === "string" ? new URL(blob) : new URL(blob.href);
295
+ } catch {
296
+ return invalidInput("Invalid URL: unable to parse the provided URL");
297
+ }
298
+ if (!isAllowedBlobUrl(url, config.apiUrl)) {
299
+ return invalidInput(notBlobStoreUrl);
300
+ }
301
+ return Effect.succeed({ url, pathname: url.pathname.slice(1) });
302
+ });
303
+ };
304
+
305
+ const get = Effect.fn("BlobStore.get")(function* (blob: BlobLocator, options: GetOptions) {
306
+ const operation = "BlobStore.get";
307
+ const locator = locatorString(blob);
308
+ if (locator === "") {
309
+ return yield* invalidInput("url or pathname is required");
310
+ }
311
+ const requestAuth = yield* auth;
312
+ if (!BlobRequestAuth.$is("Bearer")(requestAuth)) {
313
+ return yield* invalidInput("Presigned URLs are not supported for the get method");
314
+ }
315
+ // Server auth only carries store ids from `BlobConfig` or a read-write token: both StoreIds.
316
+ const storeId = requestAuth.storeId as StoreId;
317
+ const { url, pathname } = yield* resolveBlobUrl(blob, storeId, options.access);
318
+
319
+ // `useCache: false` bypasses the CDN; the store supports it for private blobs only.
320
+ const fetchUrl = new URL(url.href);
321
+ if (options.useCache === false && options.access === "private") {
322
+ fetchUrl.searchParams.set("cache", "0");
323
+ }
324
+
325
+ const response = yield* requester.fetch({
326
+ operation,
327
+ url: fetchUrl,
328
+ token: requestAuth.token,
329
+ headers: {
330
+ ...(options.ifNoneMatch ? { "if-none-match": options.ifNoneMatch } : {}),
331
+ ...options.headers,
332
+ },
333
+ });
334
+ const headers = response.headers;
335
+
336
+ if (response.status === 404) {
337
+ yield* Effect.ignore(response.arrayBuffer);
338
+ return yield* new BlobNotFound({ locator });
339
+ }
340
+ if (response.status !== 304 && (response.status < 200 || response.status >= 300)) {
341
+ yield* Effect.ignore(response.arrayBuffer);
342
+ const now = yield* Clock.currentTimeMillis;
343
+ return yield* vercelApiErrorFromStatus(
344
+ {
345
+ operation,
346
+ status: response.status,
347
+ message: `Failed to fetch blob: ${response.status}`,
348
+ errorCode: Option.none(),
349
+ headers,
350
+ },
351
+ now,
352
+ );
353
+ }
354
+
355
+ const lastModified = Option.flatMap(
356
+ Option.fromUndefinedOr(header(headers, "last-modified")),
357
+ (value) => DateTime.make(value),
358
+ );
359
+ const uploadedAt = Option.isSome(lastModified) ? lastModified.value : yield* DateTime.now;
360
+ const fetched = new FetchedBlob({
361
+ url,
362
+ downloadUrl: downloadUrl(url),
363
+ pathname,
364
+ contentDisposition: header(headers, "content-disposition") || "",
365
+ cacheControl: header(headers, "cache-control") || "",
366
+ uploadedAt,
367
+ etag: header(headers, "etag") || "",
368
+ });
369
+
370
+ if (response.status === 304) {
371
+ return GetBlobResult.NotModified({ blob: fetched, headers });
372
+ }
373
+ const contentLength = header(headers, "content-length");
374
+ return GetBlobResult.Found({
375
+ blob: fetched,
376
+ contentType: header(headers, "content-type") || "application/octet-stream",
377
+ size: contentLength ? Number.parseInt(contentLength, 10) : 0,
378
+ headers,
379
+ body: response.stream.pipe(
380
+ Stream.mapError(
381
+ (error) =>
382
+ new VercelApiError({
383
+ operation,
384
+ errorCode: Option.none(),
385
+ reason: new ApiNetworkFailure({ message: error.message, cause: error }),
386
+ }),
387
+ ),
388
+ ),
389
+ });
390
+ });
391
+
392
+ const list = Effect.fn("BlobStore.list")(function* (
393
+ options: ListOptions & { readonly cursor?: string | undefined } = {},
394
+ ) {
395
+ const query = new URLSearchParams();
396
+ if (options.limit) query.set("limit", options.limit.toString());
397
+ if (options.prefix) query.set("prefix", options.prefix);
398
+ if (options.cursor) query.set("cursor", options.cursor);
399
+ if (options.mode) query.set("mode", options.mode);
400
+ return yield* requester.call({
401
+ operation: "BlobStore.list",
402
+ method: "GET",
403
+ // An empty query is omitted: the SDK's undici drops a bare `?`, other fetches send it.
404
+ path: query.size === 0 ? "" : `?${query.toString()}`,
405
+ auth,
406
+ response: ListResponse,
407
+ domainErrors: requestErrors,
408
+ });
409
+ });
410
+
411
+ const listAll = (options: ListOptions = {}) =>
412
+ Stream.paginate(undefined as string | undefined, (cursor) =>
413
+ Effect.map(list({ ...options, cursor }), (page) => {
414
+ const next = page.hasMore ? page.cursor : Option.none();
415
+ return [page.blobs, next] as const;
416
+ }),
417
+ );
418
+
419
+ const deleteUrls = (
420
+ operation: string,
421
+ urls: ReadonlyArray<string>,
422
+ ifMatch: ETag | undefined,
423
+ ) =>
424
+ requester.call({
425
+ operation,
426
+ method: "POST",
427
+ path: "/delete",
428
+ auth,
429
+ json: { urls },
430
+ headers: ifMatch ? { "x-if-match": ifMatch } : undefined,
431
+ domainErrors: deleteErrors,
432
+ });
433
+
434
+ const del = Effect.fn("BlobStore.del")(function* (
435
+ blob: BlobLocator,
436
+ options?: { readonly ifMatch?: ETag | undefined },
437
+ ) {
438
+ return yield* deleteUrls("BlobStore.del", [locatorString(blob)], options?.ifMatch);
439
+ });
440
+
441
+ const delMany = Effect.fn("BlobStore.delMany")(function* (blobs: ReadonlyArray<BlobLocator>) {
442
+ return yield* requester.call({
443
+ operation: "BlobStore.delMany",
444
+ method: "POST",
445
+ path: "/delete",
446
+ auth,
447
+ json: { urls: blobs.map(locatorString) },
448
+ domainErrors: requestErrors,
449
+ });
450
+ });
451
+
452
+ const copy = Effect.fn("BlobStore.copy")(function* (
453
+ from: BlobLocator,
454
+ to: BlobPathname,
455
+ options: CreateBlobOptions,
456
+ ) {
457
+ const fromUrl = locatorString(from);
458
+ const query = new URLSearchParams({ pathname: to, fromUrl });
459
+ return yield* requester.call({
460
+ operation: "BlobStore.copy",
461
+ method: "PUT",
462
+ path: `?${query.toString()}`,
463
+ auth,
464
+ response: PutBlobResponse,
465
+ headers: copyWriteHeaders(options),
466
+ locator: fromUrl,
467
+ domainErrors: copyErrors,
468
+ });
469
+ });
470
+
471
+ const rename = Effect.fn("BlobStore.rename")(function* (
472
+ from: BlobLocator,
473
+ to: BlobPathname,
474
+ options: CreateBlobOptions,
475
+ ) {
476
+ const fromUrl = locatorString(from);
477
+ const query = new URLSearchParams({ pathname: to, fromUrl });
478
+ return yield* requester.call({
479
+ operation: "BlobStore.rename",
480
+ method: "POST",
481
+ path: `/rename?${query.toString()}`,
482
+ auth,
483
+ response: PutBlobResponse,
484
+ headers: copyWriteHeaders(options),
485
+ locator: fromUrl,
486
+ domainErrors: copyErrors,
487
+ });
488
+ });
489
+
490
+ const createFolder = Effect.fn("BlobStore.createFolder")(function* (
491
+ pathname: BlobPathname,
492
+ options?: { readonly access?: BlobAccess | undefined },
493
+ ) {
494
+ const folder = pathname.endsWith("/") ? pathname : `${pathname}/`;
495
+ const query = new URLSearchParams({ pathname: folder });
496
+ return yield* requester.call({
497
+ operation: "BlobStore.createFolder",
498
+ method: "PUT",
499
+ path: `/?${query.toString()}`,
500
+ auth,
501
+ response: CreateFolderResponse,
502
+ headers: {
503
+ "x-vercel-blob-access": options?.access ?? "public",
504
+ "x-add-random-suffix": "0",
505
+ },
506
+ locator: folder,
507
+ domainErrors: writeErrors,
508
+ });
509
+ });
510
+
511
+ return BlobStore.of({
512
+ put,
513
+ putImage,
514
+ head,
515
+ get,
516
+ list,
517
+ listAll,
518
+ del,
519
+ delMany,
520
+ copy,
521
+ rename,
522
+ createFolder,
523
+ createMultipartUpload: multipart.createMultipartUpload,
524
+ uploadPart: multipart.uploadPart,
525
+ completeMultipartUpload: multipart.completeMultipartUpload,
526
+ });
527
+ }),
528
+ );