@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,718 @@
1
+ import { ApiBadRequest, ApiUnexpectedResponse, VercelApiError, } from "@panorama-industries/effect-vercel-core";
2
+ import { Clock, Context, DateTime, Duration, Effect, Layer, Option, Random, Stream } from "effect";
3
+ import { BlobMetadata, CreateFolderResult, ETag, FetchedBlob, GetBlobResult, ListedBlob, ListPage, MultipartUpload, PutBlobResult, StoreId, UploadedPart, } from "../contracts/blob-domain.js";
4
+ import { BlobNotFound, BlobPreconditionFailed, BlobUploadRejected, ContentTypeNotAllowed, FileTooLarge, } from "../contracts/blob-errors.js";
5
+ import { BlobStore } from "../contracts/blob-store.js";
6
+ import { bodyLength, bodyStream } from "../internal/blob-request.js";
7
+ import { blobUrl, defaultBlobBaseUrl, downloadUrl, isUrl } from "../internal/urls.js";
8
+ import { cacheControlMaxAgeSeconds } from "../internal/write-headers.js";
9
+ const defaultStoreId = StoreId.make("teststore");
10
+ /** The store's default cache lifetime: one month (30 days), in seconds. */
11
+ const oneMonthSeconds = 30 * 24 * 60 * 60;
12
+ const defaultListLimit = 1000;
13
+ const suffixLength = 30;
14
+ const suffixAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
15
+ const textEncoder = new TextEncoder();
16
+ /**
17
+ * Media types the store infers from a pathname extension. The store knows many more; a pathname
18
+ * without one of these falls back to the request's `Content-Type`.
19
+ */
20
+ const extensionMediaTypes = {
21
+ aac: "audio/x-aac",
22
+ avif: "image/avif",
23
+ bin: "application/octet-stream",
24
+ bmp: "image/bmp",
25
+ css: "text/css",
26
+ csv: "text/csv",
27
+ gif: "image/gif",
28
+ gz: "application/gzip",
29
+ htm: "text/html",
30
+ html: "text/html",
31
+ ico: "image/vnd.microsoft.icon",
32
+ jpeg: "image/jpeg",
33
+ jpg: "image/jpeg",
34
+ heic: "image/heic",
35
+ js: "application/javascript",
36
+ json: "application/json",
37
+ m4a: "audio/mp4",
38
+ md: "text/markdown",
39
+ mjs: "application/javascript",
40
+ mov: "video/quicktime",
41
+ mp3: "audio/mpeg",
42
+ mp4: "video/mp4",
43
+ oga: "audio/ogg",
44
+ ogg: "audio/ogg",
45
+ ogv: "video/ogg",
46
+ otf: "font/otf",
47
+ pdf: "application/pdf",
48
+ png: "image/png",
49
+ svg: "image/svg+xml",
50
+ tar: "application/x-tar",
51
+ tif: "image/tiff",
52
+ tiff: "image/tiff",
53
+ ttf: "font/ttf",
54
+ txt: "text/plain",
55
+ wasm: "application/wasm",
56
+ wav: "audio/wav",
57
+ webm: "video/webm",
58
+ webp: "image/webp",
59
+ woff: "font/woff",
60
+ woff2: "font/woff2",
61
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
62
+ xml: "application/xml",
63
+ yaml: "text/yaml",
64
+ yml: "text/yaml",
65
+ zip: "application/zip",
66
+ };
67
+ const imageFormatMediaType = {
68
+ jpeg: "image/jpeg",
69
+ png: "image/png",
70
+ webp: "image/webp",
71
+ avif: "image/avif",
72
+ };
73
+ const lastSegment = (pathname) => pathname.slice(pathname.lastIndexOf("/") + 1);
74
+ /** Index of the extension's dot in the last segment, or -1; a leading dot is not an extension. */
75
+ const extensionDot = (pathname) => {
76
+ const segmentStart = pathname.lastIndexOf("/") + 1;
77
+ const dot = pathname.lastIndexOf(".");
78
+ return dot > segmentStart ? dot : -1;
79
+ };
80
+ const extensionMediaType = (pathname) => {
81
+ const dot = extensionDot(pathname);
82
+ return dot === -1 ? undefined : extensionMediaTypes[pathname.slice(dot + 1).toLowerCase()];
83
+ };
84
+ const octetStream = "application/octet-stream";
85
+ /** The `Content-Type` `fetch` sends for a body: strings are text, typed `Blob`s keep their type. */
86
+ const requestContentTypeOf = (body) => typeof body === "string"
87
+ ? "text/plain;charset=UTF-8"
88
+ : isBlob(body) && body.type !== ""
89
+ ? body.type
90
+ : octetStream;
91
+ /**
92
+ * Media types the store serves `inline`, compared exactly as stored: `text/plain` is inline but
93
+ * `text/plain; charset=utf-8` is not. Any `image/`, `audio/` or `video/` type is inline too.
94
+ */
95
+ const inlineMediaTypes = new Set([
96
+ "application/json",
97
+ "application/pdf",
98
+ "text/plain",
99
+ "text/xml",
100
+ ]);
101
+ const isInline = (contentType) => inlineMediaTypes.has(contentType) || /^(image|audio|video)\//.test(contentType);
102
+ const contentDispositionOf = (contentType, pathname) => `${isInline(contentType) ? "inline" : "attachment"}; filename="${encodeURIComponent(lastSegment(pathname))}"`;
103
+ /** The store serves every blob as `public` for caches, private ones included. */
104
+ const cacheControlOf = (seconds) => `public, max-age=${seconds}`;
105
+ /** ETags compare without their quotes or weak marker, as `If-Match` values are often sent bare. */
106
+ const sameEtag = (a, b) => {
107
+ const bare = (etag) => etag.replace(/^W\//, "").replace(/^"(.*)"$/, "$1");
108
+ return bare(a) === bare(b);
109
+ };
110
+ const concatBytes = (chunks) => {
111
+ const result = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
112
+ let offset = 0;
113
+ for (const chunk of chunks) {
114
+ result.set(chunk, offset);
115
+ offset += chunk.length;
116
+ }
117
+ return result;
118
+ };
119
+ /** Read a body completely; a stream's failures and requirements become the caller's. */
120
+ const readBody = (body) => Effect.map(Stream.runCollect(bodyStream(body)), concatBytes);
121
+ const toBytes = (body) => typeof body === "string" ? textEncoder.encode(body) : body.slice();
122
+ const isBlob = (body) => typeof globalThis.Blob !== "undefined" && body instanceof globalThis.Blob;
123
+ const locatorString = (locator) => typeof locator === "string" ? locator : locator.href;
124
+ const badRequest = (operation, message) => new VercelApiError({
125
+ operation,
126
+ errorCode: Option.some("bad_request"),
127
+ reason: new ApiBadRequest({ message }),
128
+ });
129
+ /** The store's answer to a write over an existing blob without `allowOverwrite`. */
130
+ const blobAlreadyExistsMessage = "This blob already exists, use `allowOverwrite: true` if you want to overwrite it. Or `addRandomSuffix: true` to generate a unique filename. Read more about this error in our documentation: https://vercel.link/blob-allow-overwrite";
131
+ /** The store's answer to a write whose access differs from the store's. */
132
+ const wrongAccessMessage = (store) => store === "private"
133
+ ? "Cannot use public access on a private store. The store is configured with private access."
134
+ : "Cannot use private access on a public store. The store must be configured with private access.";
135
+ const uploadNotFound = (operation) => new VercelApiError({
136
+ operation,
137
+ errorCode: Option.some("not_found"),
138
+ reason: new ApiUnexpectedResponse({
139
+ message: "The multipart upload does not exist",
140
+ status: 404,
141
+ }),
142
+ });
143
+ /** A defect carrying the SDK's `BlobError` message, as `BlobStoreLive` raises for invalid input. */
144
+ const invalidInput = (message) => Effect.die(new Error(`Vercel Blob: ${message}`));
145
+ const notBlobStoreUrl = "Invalid URL: the URL does not point to a Vercel Blob store. Use a pathname instead, see https://vercel.com/docs/vercel-blob";
146
+ /**
147
+ * Inspects and scripts the in-memory store behind {@link TestBlobStore.layer}.
148
+ *
149
+ * Time follows `Clock` and random suffixes follow `Random`, so `TestClock` and `Random.withSeed`
150
+ * make results deterministic.
151
+ */
152
+ export class TestBlobStore extends Context.Service()("@effect-vercel/blob/TestBlobStore") {
153
+ /**
154
+ * An in-memory `BlobStore` with the store's write, read, listing and multipart semantics, plus
155
+ * the {@link TestBlobStore} controls. Each build of the layer holds its own empty store.
156
+ *
157
+ * Behavior checked against real stores by `test/live` (`pnpm test:live`):
158
+ *
159
+ * - The store has one access, `public` unless set with {@link TestBlobStore.layerWith}; writes
160
+ * with the other access fail with `VercelApiError` / `ApiBadRequest` (`bad_request`).
161
+ * - Writes refuse an existing pathname with `ApiBadRequest` unless `allowOverwrite` is set,
162
+ * except `copy`, which always replaces. `ifMatch` replaces only a blob with that ETag and
163
+ * otherwise fails with `BlobPreconditionFailed`, also when nothing is stored there. Copying or
164
+ * renaming a missing blob fails with `BlobNotFound`.
165
+ * - Every write gets a new ETag and `uploadedAt` from `Clock`. `addRandomSuffix` inserts 30
166
+ * characters from `Random` before the extension.
167
+ * - Media types come from the option, then common extensions, then the request: text for a
168
+ * string body, the type of a typed `Blob`, `application/octet-stream` otherwise. Images,
169
+ * audio, video, `text/plain`, `text/xml`, `application/json` and `application/pdf` are served
170
+ * `inline`, anything else as `attachment`, with a percent-encoded filename. Cache-Control is
171
+ * `public, max-age=<seconds>` for every blob, one month by default.
172
+ * - URLs are `https://<storeId>.<access>.blob.vercel-storage.com/<pathname>`. A URL locator
173
+ * addresses a blob only with this store's host and the blob's access; `get` with a pathname
174
+ * finds it only under the given `access`.
175
+ * - `list` orders by pathname, with opaque cursors. `putImage` stores the source unchanged.
176
+ * Multipart uploads assemble their parts in part-number order; part sizes are not checked.
177
+ * - Progress reports 0 % before the body is read and 100 % once stored.
178
+ */
179
+ static layer = TestBlobStore.layerWith({});
180
+ /** {@link TestBlobStore.layer} with a chosen store id or access. */
181
+ static layerWith(options) {
182
+ return Layer.effectContext(Effect.sync(() => buildTestBlobStore(options)));
183
+ }
184
+ }
185
+ function buildTestBlobStore(options) {
186
+ const storeId = options.storeId ?? defaultStoreId;
187
+ const storeAccess = options.access ?? "public";
188
+ const storeHost = (access) => defaultBlobBaseUrl(storeId, access).hostname;
189
+ const stored = new Map();
190
+ const uploads = new Map();
191
+ const remotes = new Map();
192
+ const recorded = [];
193
+ const scripted = new Map();
194
+ let sequence = 0;
195
+ const nextEtag = () => ETag.make(`"etag-${++sequence}"`);
196
+ const urlOf = (pathname, access) => blobUrl(defaultBlobBaseUrl(storeId, access), pathname);
197
+ const view = (blob) => {
198
+ const url = urlOf(blob.pathname, blob.access);
199
+ return {
200
+ pathname: blob.pathname,
201
+ access: blob.access,
202
+ url,
203
+ downloadUrl: downloadUrl(url),
204
+ size: blob.body.length,
205
+ uploadedAt: blob.uploadedAt,
206
+ contentType: blob.contentType,
207
+ contentDisposition: contentDispositionOf(blob.contentType, blob.pathname),
208
+ cacheControl: cacheControlOf(blob.cacheControlMaxAge),
209
+ cacheControlMaxAge: blob.cacheControlMaxAge,
210
+ etag: blob.etag,
211
+ body: blob.body.slice(),
212
+ };
213
+ };
214
+ const putResult = (blob) => {
215
+ const { url, downloadUrl, pathname, contentType, contentDisposition, etag } = view(blob);
216
+ return new PutBlobResult({ url, downloadUrl, pathname, contentType, contentDisposition, etag });
217
+ };
218
+ const call = (entry, run) => Effect.flatMap(Clock.currentTimeMillis, (now) => {
219
+ recorded.push({ ...entry, at: now });
220
+ const failure = scripted.get(entry.operation)?.shift();
221
+ if (failure !== undefined) {
222
+ return Effect.fail(failure);
223
+ }
224
+ return run;
225
+ });
226
+ const suffixed = Effect.fnUntraced(function* (pathname) {
227
+ let suffix = "";
228
+ for (let index = 0; index < suffixLength; index++) {
229
+ suffix += suffixAlphabet[yield* Random.nextIntBetween(0, suffixAlphabet.length - 1)];
230
+ }
231
+ const dot = extensionDot(pathname);
232
+ return dot === -1
233
+ ? `${pathname}-${suffix}`
234
+ : `${pathname.slice(0, dot)}-${suffix}${pathname.slice(dot)}`;
235
+ });
236
+ /** Whether a write with `access` may replace what is stored at `pathname` now. */
237
+ const checkWrite = (operation, pathname, access, allowOverwrite, ifMatch) => {
238
+ if (access !== storeAccess) {
239
+ return Effect.fail(badRequest(operation, wrongAccessMessage(storeAccess)));
240
+ }
241
+ const existing = stored.get(pathname);
242
+ if (ifMatch !== undefined) {
243
+ return existing !== undefined && sameEtag(existing.etag, ifMatch)
244
+ ? Effect.void
245
+ : Effect.fail(new BlobPreconditionFailed());
246
+ }
247
+ return existing !== undefined && !allowOverwrite
248
+ ? Effect.fail(badRequest(operation, blobAlreadyExistsMessage))
249
+ : Effect.void;
250
+ };
251
+ /** Check and store in one step, so concurrent writes cannot interleave between the two. */
252
+ const commit = (input, uploadedAt) => Effect.suspend(() => Effect.map(checkWrite(input.operation, input.pathname, input.access, input.allowOverwrite, input.ifMatch), () => {
253
+ const blob = {
254
+ pathname: input.pathname,
255
+ access: input.access,
256
+ body: input.body,
257
+ contentType: input.contentType || (extensionMediaType(input.pathname) ?? input.requestContentType),
258
+ cacheControlMaxAge: input.cacheControlMaxAge ?? oneMonthSeconds,
259
+ etag: nextEtag(),
260
+ uploadedAt,
261
+ };
262
+ stored.set(blob.pathname, blob);
263
+ return blob;
264
+ }));
265
+ const write = Effect.fnUntraced(function* (input) {
266
+ const pathname = input.addRandomSuffix ? yield* suffixed(input.pathname) : input.pathname;
267
+ const uploadedAt = yield* DateTime.now;
268
+ return yield* commit({ ...input, pathname }, uploadedAt);
269
+ });
270
+ const maxAgeSeconds = (maxAge) => maxAge === undefined ? undefined : cacheControlMaxAgeSeconds(maxAge);
271
+ /** The pathname a locator names in this store, and the access its URL requires. */
272
+ const locate = (locator) => {
273
+ if (typeof locator === "string" && !isUrl(locator)) {
274
+ return Option.some({ pathname: locator, access: Option.none() });
275
+ }
276
+ let url;
277
+ try {
278
+ url = new URL(locatorString(locator));
279
+ }
280
+ catch {
281
+ return Option.none();
282
+ }
283
+ const access = ["public", "private"].find((candidate) => url.hostname === storeHost(candidate));
284
+ if (access === undefined)
285
+ return Option.none();
286
+ try {
287
+ return Option.some({
288
+ pathname: decodeURIComponent(url.pathname.slice(1)),
289
+ access: Option.some(access),
290
+ });
291
+ }
292
+ catch {
293
+ return Option.none();
294
+ }
295
+ };
296
+ const find = (locator, access) => Option.flatMap(locate(locator), (located) => {
297
+ const blob = stored.get(located.pathname);
298
+ const required = Option.getOrElse(located.access, () => access);
299
+ if (blob === undefined || (required !== undefined && blob.access !== required)) {
300
+ return Option.none();
301
+ }
302
+ return Option.some(blob);
303
+ });
304
+ const progressed = (onUploadProgress, total, run) => onUploadProgress === undefined
305
+ ? run
306
+ : Effect.gen(function* () {
307
+ yield* onUploadProgress({ loaded: 0, total, percentage: 0 });
308
+ const result = yield* run;
309
+ const loaded = result.body.length;
310
+ yield* onUploadProgress({ loaded, total: loaded, percentage: 100 });
311
+ return result;
312
+ });
313
+ const put = Effect.fn("BlobStore.put")(function* (pathname, body, options) {
314
+ const operation = "BlobStore.put";
315
+ const total = bodyLength(body);
316
+ const maximum = options.maximumSizeInBytes;
317
+ const run = Effect.gen(function* () {
318
+ if (options.multipart === true &&
319
+ maximum !== undefined &&
320
+ !Stream.isStream(body) &&
321
+ total > maximum) {
322
+ return yield* new BlobUploadRejected({
323
+ reason: new FileTooLarge({
324
+ message: `Body size of ${total} bytes exceeds the maximum allowed size of ${maximum} bytes`,
325
+ }),
326
+ });
327
+ }
328
+ return yield* progressed(options.onUploadProgress, total, Effect.flatMap(readBody(body), (bytes) => write({
329
+ operation,
330
+ pathname,
331
+ addRandomSuffix: options.addRandomSuffix ?? false,
332
+ access: options.access,
333
+ body: bytes,
334
+ contentType: options.contentType,
335
+ requestContentType: options.multipart === true ? octetStream : requestContentTypeOf(body),
336
+ cacheControlMaxAge: maxAgeSeconds(options.cacheControlMaxAge),
337
+ allowOverwrite: options.allowOverwrite === true || options.ifMatch !== undefined,
338
+ ifMatch: options.ifMatch,
339
+ })));
340
+ });
341
+ return putResult(yield* call({ operation: "put", pathname }, run));
342
+ });
343
+ const imageSource = (source) => {
344
+ if (!(source instanceof URL)) {
345
+ return Effect.map(readBody(source), (body) => ({
346
+ body,
347
+ contentType: isBlob(source) && source.type !== "" ? Option.some(source.type) : Option.none(),
348
+ }));
349
+ }
350
+ const blob = find(source);
351
+ if (Option.isSome(blob)) {
352
+ return Effect.succeed({
353
+ body: blob.value.body,
354
+ contentType: Option.some(blob.value.contentType),
355
+ });
356
+ }
357
+ const remote = remotes.get(source.href);
358
+ return remote === undefined
359
+ ? Effect.fail(badRequest("BlobStore.putImage", `Failed to fetch the image at ${source.href}`))
360
+ : Effect.succeed(remote);
361
+ };
362
+ const putImage = Effect.fn("BlobStore.putImage")(function* (pathname, source, options) {
363
+ if (source instanceof URL && source.protocol !== "http:" && source.protocol !== "https:") {
364
+ return yield* invalidInput("the source URL must use the http(s) protocol");
365
+ }
366
+ const run = Effect.gen(function* () {
367
+ if (isBlob(source) &&
368
+ source.type !== "" &&
369
+ source.type !== "application/octet-stream" &&
370
+ !source.type.startsWith("image/")) {
371
+ return yield* new BlobUploadRejected({
372
+ reason: new ContentTypeNotAllowed({
373
+ message: `optimizeImage requires an image body, but the content type is "${source.type}"`,
374
+ }),
375
+ });
376
+ }
377
+ const format = options.optimizeImage.format;
378
+ return yield* progressed(options.onUploadProgress, source instanceof URL ? 0 : bodyLength(source), Effect.flatMap(imageSource(source), (image) => write({
379
+ operation: "BlobStore.putImage",
380
+ pathname,
381
+ addRandomSuffix: options.addRandomSuffix ?? false,
382
+ access: options.access,
383
+ body: image.body,
384
+ contentType: format === undefined
385
+ ? Option.getOrUndefined(image.contentType)
386
+ : imageFormatMediaType[format],
387
+ requestContentType: octetStream,
388
+ cacheControlMaxAge: maxAgeSeconds(options.cacheControlMaxAge),
389
+ allowOverwrite: options.allowOverwrite === true || options.ifMatch !== undefined,
390
+ ifMatch: options.ifMatch,
391
+ })));
392
+ });
393
+ return putResult(yield* call({ operation: "putImage", pathname }, run));
394
+ });
395
+ const head = Effect.fn("BlobStore.head")(function* (blob) {
396
+ const locator = locatorString(blob);
397
+ const run = Effect.suspend(() => Option.match(find(blob), {
398
+ onNone: () => Effect.fail(new BlobNotFound({ locator })),
399
+ onSome: (found) => {
400
+ const current = view(found);
401
+ return Effect.succeed(new BlobMetadata({
402
+ url: current.url,
403
+ downloadUrl: current.downloadUrl,
404
+ pathname: current.pathname,
405
+ size: current.size,
406
+ uploadedAt: current.uploadedAt,
407
+ contentType: current.contentType,
408
+ contentDisposition: current.contentDisposition,
409
+ cacheControl: current.cacheControl,
410
+ etag: current.etag,
411
+ }));
412
+ },
413
+ }));
414
+ return yield* call({ operation: "head", locators: [locator] }, run);
415
+ });
416
+ const get = Effect.fn("BlobStore.get")(function* (blob, options) {
417
+ const locator = locatorString(blob);
418
+ if (locator === "") {
419
+ return yield* invalidInput("url or pathname is required");
420
+ }
421
+ if (isUrl(locator)) {
422
+ let url;
423
+ try {
424
+ url = new URL(locator);
425
+ }
426
+ catch {
427
+ return yield* invalidInput("Invalid URL: unable to parse the provided URL");
428
+ }
429
+ if (!url.hostname.endsWith(".blob.vercel-storage.com")) {
430
+ return yield* invalidInput(notBlobStoreUrl);
431
+ }
432
+ }
433
+ const run = Effect.suspend(() => Option.match(find(blob, options.access), {
434
+ onNone: () => Effect.fail(new BlobNotFound({ locator })),
435
+ onSome: (found) => {
436
+ const current = view(found);
437
+ const validators = {
438
+ "cache-control": current.cacheControl,
439
+ "content-disposition": current.contentDisposition,
440
+ etag: current.etag,
441
+ "last-modified": DateTime.toDateUtc(current.uploadedAt).toUTCString(),
442
+ };
443
+ const fetched = new FetchedBlob({
444
+ url: current.url,
445
+ downloadUrl: current.downloadUrl,
446
+ pathname: current.pathname,
447
+ contentDisposition: current.contentDisposition,
448
+ cacheControl: current.cacheControl,
449
+ uploadedAt: current.uploadedAt,
450
+ etag: current.etag,
451
+ });
452
+ if (options.ifNoneMatch !== undefined && sameEtag(current.etag, options.ifNoneMatch)) {
453
+ return Effect.succeed(GetBlobResult.NotModified({ blob: fetched, headers: validators }));
454
+ }
455
+ return Effect.succeed(GetBlobResult.Found({
456
+ blob: fetched,
457
+ contentType: current.contentType,
458
+ size: current.size,
459
+ headers: {
460
+ ...validators,
461
+ "content-length": String(current.size),
462
+ "content-type": current.contentType,
463
+ },
464
+ body: Stream.succeed(current.body),
465
+ }));
466
+ },
467
+ }));
468
+ return yield* call({ operation: "get", locators: [locator] }, run);
469
+ });
470
+ /** The page's entries: blobs, and in folded mode the folders directly under the prefix. */
471
+ const listEntries = (args) => {
472
+ const prefix = args.prefix ?? "";
473
+ const folded = args.mode === "folded";
474
+ const entries = new Map();
475
+ for (const blob of stored.values()) {
476
+ if (!blob.pathname.startsWith(prefix))
477
+ continue;
478
+ const slash = folded ? blob.pathname.indexOf("/", prefix.length) : -1;
479
+ if (slash === -1) {
480
+ entries.set(blob.pathname, Option.some(blob));
481
+ }
482
+ else {
483
+ entries.set(blob.pathname.slice(0, slash + 1), Option.none());
484
+ }
485
+ }
486
+ return [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
487
+ };
488
+ const listPage = (args) => Effect.sync(() => {
489
+ const limit = args.limit && args.limit > 0 ? Math.min(args.limit, 1000) : defaultListLimit;
490
+ const after = args.cursor;
491
+ const remaining = listEntries(args).filter(([key]) => after === undefined || key > after);
492
+ const page = remaining.slice(0, limit);
493
+ const hasMore = remaining.length > page.length;
494
+ return new ListPage({
495
+ blobs: page.flatMap(([, entry]) => Option.match(entry, {
496
+ onNone: () => [],
497
+ onSome: (blob) => {
498
+ const { url, downloadUrl, pathname, size, uploadedAt, etag } = view(blob);
499
+ return [new ListedBlob({ url, downloadUrl, pathname, size, uploadedAt, etag })];
500
+ },
501
+ })),
502
+ folders: page.flatMap(([key, entry]) => (Option.isNone(entry) ? [key] : [])),
503
+ cursor: hasMore ? Option.some(page[page.length - 1][0]) : Option.none(),
504
+ hasMore,
505
+ });
506
+ });
507
+ const list = Effect.fn("BlobStore.list")(function* (args = {}) {
508
+ return yield* call({
509
+ operation: "list",
510
+ ...(args.prefix === undefined ? {} : { prefix: args.prefix }),
511
+ ...(args.cursor === undefined ? {} : { cursor: args.cursor }),
512
+ }, listPage(args));
513
+ });
514
+ const listAll = (args = {}) => Stream.paginate(undefined, (cursor) => Effect.map(list({ ...args, cursor }), (page) => {
515
+ const next = page.hasMore ? page.cursor : Option.none();
516
+ return [page.blobs, next];
517
+ }));
518
+ const del = Effect.fn("BlobStore.del")(function* (blob, options) {
519
+ const run = Effect.suspend(() => {
520
+ const found = find(blob);
521
+ const ifMatch = options?.ifMatch;
522
+ if (ifMatch !== undefined && (Option.isNone(found) || !sameEtag(found.value.etag, ifMatch))) {
523
+ return Effect.fail(new BlobPreconditionFailed());
524
+ }
525
+ if (Option.isSome(found))
526
+ stored.delete(found.value.pathname);
527
+ return Effect.void;
528
+ });
529
+ return yield* call({ operation: "del", locators: [locatorString(blob)] }, run);
530
+ });
531
+ const delMany = Effect.fn("BlobStore.delMany")(function* (blobs) {
532
+ const run = Effect.sync(() => {
533
+ for (const blob of blobs) {
534
+ const found = find(blob);
535
+ if (Option.isSome(found))
536
+ stored.delete(found.value.pathname);
537
+ }
538
+ });
539
+ return yield* call({ operation: "delMany", locators: blobs.map(locatorString) }, run);
540
+ });
541
+ /** Copy `from` to `to` without inheriting its media type or cache lifetime. */
542
+ const copyBlob = (operation, from, to, options, allowOverwrite) => Effect.gen(function* () {
543
+ const source = find(from);
544
+ if (Option.isNone(source)) {
545
+ return yield* new BlobNotFound({ locator: locatorString(from) });
546
+ }
547
+ if (options.ifMatch !== undefined && !sameEtag(source.value.etag, options.ifMatch)) {
548
+ return yield* new BlobPreconditionFailed();
549
+ }
550
+ const copied = yield* write({
551
+ operation,
552
+ pathname: to,
553
+ addRandomSuffix: options.addRandomSuffix ?? false,
554
+ access: options.access,
555
+ body: source.value.body,
556
+ contentType: options.contentType,
557
+ requestContentType: octetStream,
558
+ cacheControlMaxAge: maxAgeSeconds(options.cacheControlMaxAge),
559
+ allowOverwrite,
560
+ ifMatch: undefined,
561
+ });
562
+ return { source: source.value, copied };
563
+ });
564
+ const copy = Effect.fn("BlobStore.copy")(function* (from, to, options) {
565
+ // The store replaces an existing blob at `to` whatever `allowOverwrite` says.
566
+ const run = Effect.map(copyBlob("BlobStore.copy", from, to, options, true), ({ copied }) => putResult(copied));
567
+ return yield* call({ operation: "copy", pathname: to, locators: [locatorString(from)] }, run);
568
+ });
569
+ const rename = Effect.fn("BlobStore.rename")(function* (from, to, options) {
570
+ const run = Effect.map(copyBlob("BlobStore.rename", from, to, options, options.allowOverwrite === true), ({ source, copied }) => {
571
+ if (source.pathname !== copied.pathname && stored.get(source.pathname) === source) {
572
+ stored.delete(source.pathname);
573
+ }
574
+ return putResult(copied);
575
+ });
576
+ return yield* call({ operation: "rename", pathname: to, locators: [locatorString(from)] }, run);
577
+ });
578
+ const createFolder = Effect.fn("BlobStore.createFolder")(function* (pathname, options) {
579
+ const folder = pathname.endsWith("/") ? pathname : `${pathname}/`;
580
+ const run = Effect.map(write({
581
+ operation: "BlobStore.createFolder",
582
+ pathname: folder,
583
+ addRandomSuffix: false,
584
+ access: options?.access ?? "public",
585
+ body: new Uint8Array(0),
586
+ contentType: undefined,
587
+ requestContentType: octetStream,
588
+ cacheControlMaxAge: undefined,
589
+ allowOverwrite: false,
590
+ ifMatch: undefined,
591
+ }), (blob) => new CreateFolderResult({ url: urlOf(blob.pathname, blob.access), pathname: folder }));
592
+ return yield* call({ operation: "createFolder", pathname }, run);
593
+ });
594
+ const createMultipartUpload = Effect.fn("BlobStore.createMultipartUpload")(function* (pathname, options) {
595
+ const operation = "BlobStore.createMultipartUpload";
596
+ const run = Effect.gen(function* () {
597
+ const key = (options.addRandomSuffix ?? true) ? yield* suffixed(pathname) : pathname;
598
+ yield* checkWrite(operation, key, options.access, options.allowOverwrite === true || options.ifMatch !== undefined, options.ifMatch);
599
+ const uploadId = `upload-${++sequence}`;
600
+ uploads.set(uploadId, { key, ifMatch: options.ifMatch, parts: new Map() });
601
+ const maxAge = maxAgeSeconds(options.cacheControlMaxAge);
602
+ return new MultipartUpload({
603
+ pathname,
604
+ key,
605
+ uploadId,
606
+ access: options.access,
607
+ ...(options.addRandomSuffix === undefined
608
+ ? {}
609
+ : { addRandomSuffix: options.addRandomSuffix }),
610
+ ...(options.ifMatch !== undefined
611
+ ? { allowOverwrite: true }
612
+ : options.allowOverwrite === undefined
613
+ ? {}
614
+ : { allowOverwrite: options.allowOverwrite }),
615
+ ...(options.contentType ? { contentType: options.contentType } : {}),
616
+ ...(maxAge === undefined ? {} : { cacheControlMaxAge: maxAge }),
617
+ });
618
+ });
619
+ return yield* call({ operation: "createMultipartUpload", pathname }, run);
620
+ });
621
+ const pendingUpload = (operation, upload) => Effect.suspend(() => {
622
+ const pending = uploads.get(upload.uploadId);
623
+ return pending === undefined || pending.key !== upload.key
624
+ ? Effect.fail(uploadNotFound(operation))
625
+ : Effect.succeed(pending);
626
+ });
627
+ const uploadPart = Effect.fn("BlobStore.uploadPart")(function* (upload, partNumber, body) {
628
+ const run = Effect.gen(function* () {
629
+ yield* pendingUpload("BlobStore.uploadPart", upload);
630
+ const bytes = yield* readBody(body);
631
+ const pending = yield* pendingUpload("BlobStore.uploadPart", upload);
632
+ const etag = `"part-${++sequence}"`;
633
+ pending.parts.set(partNumber, { etag, bytes });
634
+ return new UploadedPart({ etag, partNumber });
635
+ });
636
+ return yield* call({ operation: "uploadPart", pathname: upload.pathname, partNumber }, run);
637
+ });
638
+ const completeMultipartUpload = Effect.fn("BlobStore.completeMultipartUpload")(function* (upload, parts) {
639
+ const operation = "BlobStore.completeMultipartUpload";
640
+ const run = Effect.gen(function* () {
641
+ const pending = yield* pendingUpload(operation, upload);
642
+ const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
643
+ const chunks = [];
644
+ for (const part of ordered) {
645
+ const uploaded = pending.parts.get(part.partNumber);
646
+ if (uploaded === undefined || !sameEtag(uploaded.etag, part.etag)) {
647
+ return yield* badRequest(operation, "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified ETag may not match the part's ETag.");
648
+ }
649
+ chunks.push(uploaded.bytes);
650
+ }
651
+ const uploadedAt = yield* DateTime.now;
652
+ const blob = yield* commit({
653
+ operation,
654
+ pathname: pending.key,
655
+ access: upload.access,
656
+ body: concatBytes(chunks),
657
+ contentType: upload.contentType,
658
+ requestContentType: octetStream,
659
+ cacheControlMaxAge: upload.cacheControlMaxAge,
660
+ allowOverwrite: upload.allowOverwrite === true || pending.ifMatch !== undefined,
661
+ ifMatch: pending.ifMatch,
662
+ }, uploadedAt);
663
+ uploads.delete(upload.uploadId);
664
+ return putResult(blob);
665
+ });
666
+ return yield* call({ operation: "completeMultipartUpload", pathname: upload.pathname }, run);
667
+ });
668
+ const api = BlobStore.of({
669
+ put,
670
+ putImage,
671
+ head,
672
+ get,
673
+ list,
674
+ listAll,
675
+ del,
676
+ delMany,
677
+ copy,
678
+ rename,
679
+ createFolder,
680
+ createMultipartUpload,
681
+ uploadPart,
682
+ completeMultipartUpload,
683
+ });
684
+ const control = TestBlobStore.of({
685
+ calls: Effect.sync(() => [...recorded]),
686
+ blobs: Effect.sync(() => [...stored.values()]
687
+ .sort((a, b) => (a.pathname < b.pathname ? -1 : a.pathname > b.pathname ? 1 : 0))
688
+ .map(view)),
689
+ blob: (pathname) => Effect.sync(() => Option.map(Option.fromUndefinedOr(stored.get(pathname)), view)),
690
+ seed: (pathname, body, seedOptions) => Effect.map(DateTime.now, (uploadedAt) => {
691
+ const blob = {
692
+ pathname,
693
+ access: seedOptions?.access ?? storeAccess,
694
+ body: toBytes(body),
695
+ contentType: seedOptions?.contentType ||
696
+ (extensionMediaType(pathname) ?? requestContentTypeOf(body)),
697
+ cacheControlMaxAge: maxAgeSeconds(seedOptions?.cacheControlMaxAge) ?? oneMonthSeconds,
698
+ etag: nextEtag(),
699
+ uploadedAt,
700
+ };
701
+ stored.set(pathname, blob);
702
+ return view(blob);
703
+ }),
704
+ failNext: (operation, error) => Effect.sync(() => {
705
+ const queue = scripted.get(operation) ?? [];
706
+ queue.push(error);
707
+ scripted.set(operation, queue);
708
+ }),
709
+ serveUrl: (url, body, contentType) => Effect.sync(() => {
710
+ remotes.set(new URL(url).href, {
711
+ body: toBytes(body),
712
+ contentType: Option.fromUndefinedOr(contentType),
713
+ });
714
+ }),
715
+ });
716
+ return Context.make(BlobStore, api).pipe(Context.add(TestBlobStore, control));
717
+ }
718
+ //# sourceMappingURL=test-blob-store.js.map