@panorama-industries/effect-vercel-blob 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +13 -2
- package/dist/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +8 -0
- package/dist/client.js.map +1 -0
- package/dist/contracts/blob-client.d.ts +67 -0
- package/dist/contracts/blob-client.d.ts.map +1 -0
- package/dist/contracts/blob-client.js +11 -0
- package/dist/contracts/blob-client.js.map +1 -0
- package/dist/contracts/blob-config.d.ts +114 -0
- package/dist/contracts/blob-config.d.ts.map +1 -0
- package/dist/contracts/blob-config.js +7 -0
- package/dist/contracts/blob-config.js.map +1 -0
- package/dist/contracts/blob-domain.d.ts +336 -0
- package/dist/contracts/blob-domain.d.ts.map +1 -0
- package/dist/contracts/blob-domain.js +152 -0
- package/dist/contracts/blob-domain.js.map +1 -0
- package/dist/contracts/blob-errors.d.ts +125 -0
- package/dist/contracts/blob-errors.d.ts.map +1 -0
- package/dist/contracts/blob-errors.js +114 -0
- package/dist/contracts/blob-errors.js.map +1 -0
- package/dist/contracts/blob-signed-urls.d.ts +83 -0
- package/dist/contracts/blob-signed-urls.d.ts.map +1 -0
- package/dist/contracts/blob-signed-urls.js +10 -0
- package/dist/contracts/blob-signed-urls.js.map +1 -0
- package/dist/contracts/blob-store.d.ts +153 -0
- package/dist/contracts/blob-store.d.ts.map +1 -0
- package/dist/contracts/blob-store.js +15 -0
- package/dist/contracts/blob-store.js.map +1 -0
- package/dist/contracts/blob-uploads.d.ts +101 -0
- package/dist/contracts/blob-uploads.d.ts.map +1 -0
- package/dist/contracts/blob-uploads.js +17 -0
- package/dist/contracts/blob-uploads.js.map +1 -0
- package/dist/implementation/blob-client-live.d.ts +37 -0
- package/dist/implementation/blob-client-live.d.ts.map +1 -0
- package/dist/implementation/blob-client-live.js +213 -0
- package/dist/implementation/blob-client-live.js.map +1 -0
- package/dist/implementation/blob-config-live.d.ts +49 -0
- package/dist/implementation/blob-config-live.d.ts.map +1 -0
- package/dist/implementation/blob-config-live.js +82 -0
- package/dist/implementation/blob-config-live.js.map +1 -0
- package/dist/implementation/blob-live.d.ts +16 -0
- package/dist/implementation/blob-live.d.ts.map +1 -0
- package/dist/implementation/blob-live.js +16 -0
- package/dist/implementation/blob-live.js.map +1 -0
- package/dist/implementation/blob-signed-urls-live.d.ts +12 -0
- package/dist/implementation/blob-signed-urls-live.d.ts.map +1 -0
- package/dist/implementation/blob-signed-urls-live.js +92 -0
- package/dist/implementation/blob-signed-urls-live.js.map +1 -0
- package/dist/implementation/blob-store-live.d.ts +18 -0
- package/dist/implementation/blob-store-live.d.ts.map +1 -0
- package/dist/implementation/blob-store-live.js +364 -0
- package/dist/implementation/blob-store-live.js.map +1 -0
- package/dist/implementation/blob-uploads-live.d.ts +11 -0
- package/dist/implementation/blob-uploads-live.d.ts.map +1 -0
- package/dist/implementation/blob-uploads-live.js +218 -0
- package/dist/implementation/blob-uploads-live.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/blob-errors.d.ts +79 -0
- package/dist/internal/blob-errors.d.ts.map +1 -0
- package/dist/internal/blob-errors.js +196 -0
- package/dist/internal/blob-errors.js.map +1 -0
- package/dist/internal/blob-request.d.ts +255 -0
- package/dist/internal/blob-request.d.ts.map +1 -0
- package/dist/internal/blob-request.js +291 -0
- package/dist/internal/blob-request.js.map +1 -0
- package/dist/internal/callback.d.ts +70 -0
- package/dist/internal/callback.d.ts.map +1 -0
- package/dist/internal/callback.js +141 -0
- package/dist/internal/callback.js.map +1 -0
- package/dist/internal/client-token.d.ts +65 -0
- package/dist/internal/client-token.d.ts.map +1 -0
- package/dist/internal/client-token.js +80 -0
- package/dist/internal/client-token.js.map +1 -0
- package/dist/internal/env.d.ts +34 -0
- package/dist/internal/env.d.ts.map +1 -0
- package/dist/internal/env.js +61 -0
- package/dist/internal/env.js.map +1 -0
- package/dist/internal/multipart.d.ts +110 -0
- package/dist/internal/multipart.d.ts.map +1 -0
- package/dist/internal/multipart.js +290 -0
- package/dist/internal/multipart.js.map +1 -0
- package/dist/internal/presign.d.ts +123 -0
- package/dist/internal/presign.d.ts.map +1 -0
- package/dist/internal/presign.js +345 -0
- package/dist/internal/presign.js.map +1 -0
- package/dist/internal/store-id.d.ts +38 -0
- package/dist/internal/store-id.d.ts.map +1 -0
- package/dist/internal/store-id.js +54 -0
- package/dist/internal/store-id.js.map +1 -0
- package/dist/internal/urls.d.ts +26 -0
- package/dist/internal/urls.d.ts.map +1 -0
- package/dist/internal/urls.js +33 -0
- package/dist/internal/urls.js.map +1 -0
- package/dist/internal/wire.d.ts +55 -0
- package/dist/internal/wire.d.ts.map +1 -0
- package/dist/internal/wire.js +64 -0
- package/dist/internal/wire.js.map +1 -0
- package/dist/internal/write-headers.d.ts +56 -0
- package/dist/internal/write-headers.d.ts.map +1 -0
- package/dist/internal/write-headers.js +82 -0
- package/dist/internal/write-headers.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/test-blob-store.d.ts +134 -0
- package/dist/testing/test-blob-store.d.ts.map +1 -0
- package/dist/testing/test-blob-store.js +718 -0
- package/dist/testing/test-blob-store.js.map +1 -0
- package/package.json +65 -3
- package/src/client.ts +11 -0
- package/src/contracts/blob-client.ts +121 -0
- package/src/contracts/blob-config.ts +64 -0
- package/src/contracts/blob-domain.ts +304 -0
- package/src/contracts/blob-errors.ts +161 -0
- package/src/contracts/blob-signed-urls.ts +112 -0
- package/src/contracts/blob-store.ts +258 -0
- package/src/contracts/blob-uploads.ts +151 -0
- package/src/implementation/blob-client-live.ts +387 -0
- package/src/implementation/blob-config-live.ts +145 -0
- package/src/implementation/blob-live.ts +30 -0
- package/src/implementation/blob-signed-urls-live.ts +121 -0
- package/src/implementation/blob-store-live.ts +528 -0
- package/src/implementation/blob-uploads-live.ts +326 -0
- package/src/index.ts +18 -0
- package/src/internal/blob-errors.ts +325 -0
- package/src/internal/blob-request.ts +617 -0
- package/src/internal/callback.ts +200 -0
- package/src/internal/client-token.ts +132 -0
- package/src/internal/env.ts +128 -0
- package/src/internal/multipart.ts +526 -0
- package/src/internal/presign.ts +477 -0
- package/src/internal/store-id.ts +69 -0
- package/src/internal/urls.ts +42 -0
- package/src/internal/wire.ts +80 -0
- package/src/internal/write-headers.ts +118 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/test-blob-store.ts +1136 -0
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ApiBadRequest,
|
|
3
|
+
ApiUnexpectedResponse,
|
|
4
|
+
VercelApiError,
|
|
5
|
+
} from "@panorama-industries/effect-vercel-core";
|
|
6
|
+
import { Clock, Context, DateTime, Duration, Effect, Layer, Option, Random, Stream } from "effect";
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
BlobAccess,
|
|
10
|
+
BlobBody,
|
|
11
|
+
BlobLocator,
|
|
12
|
+
BlobPathname,
|
|
13
|
+
CreateBlobOptions,
|
|
14
|
+
MultipartUpload as MultipartUploadType,
|
|
15
|
+
PartNumber,
|
|
16
|
+
} from "../contracts/blob-domain.ts";
|
|
17
|
+
import type {
|
|
18
|
+
BlobRequestError,
|
|
19
|
+
BlobWriteError,
|
|
20
|
+
CreateMultipartUploadOptions,
|
|
21
|
+
GetOptions,
|
|
22
|
+
ImageFormat,
|
|
23
|
+
ListOptions,
|
|
24
|
+
PutImageOptions,
|
|
25
|
+
PutOptions,
|
|
26
|
+
} from "../contracts/blob-store.ts";
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
BlobMetadata,
|
|
30
|
+
CreateFolderResult,
|
|
31
|
+
ETag,
|
|
32
|
+
FetchedBlob,
|
|
33
|
+
GetBlobResult,
|
|
34
|
+
ListedBlob,
|
|
35
|
+
ListPage,
|
|
36
|
+
MultipartUpload,
|
|
37
|
+
PutBlobResult,
|
|
38
|
+
StoreId,
|
|
39
|
+
UploadedPart,
|
|
40
|
+
} from "../contracts/blob-domain.ts";
|
|
41
|
+
import {
|
|
42
|
+
BlobNotFound,
|
|
43
|
+
BlobPreconditionFailed,
|
|
44
|
+
BlobUploadRejected,
|
|
45
|
+
ContentTypeNotAllowed,
|
|
46
|
+
FileTooLarge,
|
|
47
|
+
} from "../contracts/blob-errors.ts";
|
|
48
|
+
import { BlobStore } from "../contracts/blob-store.ts";
|
|
49
|
+
import { bodyLength, bodyStream } from "../internal/blob-request.ts";
|
|
50
|
+
import { blobUrl, defaultBlobBaseUrl, downloadUrl, isUrl } from "../internal/urls.ts";
|
|
51
|
+
import { cacheControlMaxAgeSeconds } from "../internal/write-headers.ts";
|
|
52
|
+
|
|
53
|
+
/** A `BlobStore` operation, as recorded by {@link TestBlobStore}. */
|
|
54
|
+
export type TestBlobStoreOperation = keyof TestBlobStoreOperationError;
|
|
55
|
+
|
|
56
|
+
/** Errors each operation can be scripted to fail with. `listAll` fails through `list`. */
|
|
57
|
+
export interface TestBlobStoreOperationError {
|
|
58
|
+
readonly put: BlobWriteError;
|
|
59
|
+
readonly putImage: BlobWriteError;
|
|
60
|
+
readonly head: BlobRequestError | BlobNotFound;
|
|
61
|
+
readonly get: BlobRequestError | BlobNotFound;
|
|
62
|
+
readonly list: BlobRequestError;
|
|
63
|
+
readonly del: BlobRequestError | BlobPreconditionFailed;
|
|
64
|
+
readonly delMany: BlobRequestError;
|
|
65
|
+
readonly copy: BlobWriteError | BlobNotFound;
|
|
66
|
+
readonly rename: BlobWriteError | BlobNotFound;
|
|
67
|
+
readonly createFolder: BlobWriteError;
|
|
68
|
+
readonly createMultipartUpload: BlobWriteError;
|
|
69
|
+
readonly uploadPart: BlobWriteError;
|
|
70
|
+
readonly completeMultipartUpload: BlobWriteError;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One call made to the test `BlobStore`, in call order. */
|
|
74
|
+
export interface TestBlobStoreCall {
|
|
75
|
+
readonly operation: TestBlobStoreOperation;
|
|
76
|
+
/** When the call was made, in epoch milliseconds. */
|
|
77
|
+
readonly at: number;
|
|
78
|
+
/** The pathname written: the requested one, before any random suffix. */
|
|
79
|
+
readonly pathname?: string;
|
|
80
|
+
/** The blobs read, deleted, copied or renamed, as given. */
|
|
81
|
+
readonly locators?: ReadonlyArray<string>;
|
|
82
|
+
readonly partNumber?: number;
|
|
83
|
+
readonly prefix?: string;
|
|
84
|
+
readonly cursor?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A blob held by the in-memory store, with everything the store reports about it. */
|
|
88
|
+
export interface TestBlob {
|
|
89
|
+
/** Final pathname, including any random suffix. */
|
|
90
|
+
readonly pathname: string;
|
|
91
|
+
readonly access: BlobAccess;
|
|
92
|
+
readonly url: URL;
|
|
93
|
+
readonly downloadUrl: URL;
|
|
94
|
+
readonly size: number;
|
|
95
|
+
readonly uploadedAt: DateTime.Utc;
|
|
96
|
+
readonly contentType: string;
|
|
97
|
+
readonly contentDisposition: string;
|
|
98
|
+
/** The `Cache-Control` value served, such as `public, max-age=2592000`. */
|
|
99
|
+
readonly cacheControl: string;
|
|
100
|
+
/** Cache lifetime in seconds. */
|
|
101
|
+
readonly cacheControlMaxAge: number;
|
|
102
|
+
readonly etag: ETag;
|
|
103
|
+
/** A copy of the content. */
|
|
104
|
+
readonly body: Uint8Array;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** How {@link TestBlobStore} `seed` stores a blob. */
|
|
108
|
+
export interface TestBlobSeedOptions {
|
|
109
|
+
/** The store's access by default. */
|
|
110
|
+
readonly access?: BlobAccess | undefined;
|
|
111
|
+
|
|
112
|
+
/** Inferred from the pathname extension by default, like a write. */
|
|
113
|
+
readonly contentType?: string | undefined;
|
|
114
|
+
|
|
115
|
+
/** One month by default. */
|
|
116
|
+
readonly cacheControlMaxAge?: Duration.Input | undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Options of {@link TestBlobStore.layerWith}. */
|
|
120
|
+
export interface TestBlobStoreOptions {
|
|
121
|
+
/** Store id used in blob URLs; `teststore` by default. */
|
|
122
|
+
readonly storeId?: StoreId | undefined;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The access every blob of the store has, `public` by default. Like a real store, writes with
|
|
126
|
+
* the other access fail.
|
|
127
|
+
*/
|
|
128
|
+
readonly access?: BlobAccess | undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface StoredBlob {
|
|
132
|
+
readonly pathname: string;
|
|
133
|
+
readonly access: BlobAccess;
|
|
134
|
+
readonly body: Uint8Array;
|
|
135
|
+
readonly contentType: string;
|
|
136
|
+
readonly cacheControlMaxAge: number;
|
|
137
|
+
readonly etag: ETag;
|
|
138
|
+
readonly uploadedAt: DateTime.Utc;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface PendingUpload {
|
|
142
|
+
readonly key: string;
|
|
143
|
+
/** Checked again at completion, since parts and completion do not resend it. */
|
|
144
|
+
readonly ifMatch: string | undefined;
|
|
145
|
+
readonly parts: Map<number, { readonly etag: string; readonly bytes: Uint8Array }>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface RemoteSource {
|
|
149
|
+
readonly body: Uint8Array;
|
|
150
|
+
readonly contentType: Option.Option<string>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** What a write stores, before the pathname gets its suffix. */
|
|
154
|
+
interface WriteInput {
|
|
155
|
+
readonly operation: string;
|
|
156
|
+
readonly pathname: string;
|
|
157
|
+
readonly addRandomSuffix: boolean;
|
|
158
|
+
readonly access: BlobAccess;
|
|
159
|
+
readonly body: Uint8Array;
|
|
160
|
+
readonly contentType: string | undefined;
|
|
161
|
+
/** The request's `Content-Type`, used when the pathname has no known extension. */
|
|
162
|
+
readonly requestContentType: string;
|
|
163
|
+
readonly cacheControlMaxAge: number | undefined;
|
|
164
|
+
readonly allowOverwrite: boolean;
|
|
165
|
+
/** Condition on the blob being replaced; `copy` and `rename` check the source instead. */
|
|
166
|
+
readonly ifMatch: string | undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const defaultStoreId = StoreId.make("teststore");
|
|
170
|
+
|
|
171
|
+
/** The store's default cache lifetime: one month (30 days), in seconds. */
|
|
172
|
+
const oneMonthSeconds = 30 * 24 * 60 * 60;
|
|
173
|
+
|
|
174
|
+
const defaultListLimit = 1000;
|
|
175
|
+
|
|
176
|
+
const suffixLength = 30;
|
|
177
|
+
const suffixAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
178
|
+
|
|
179
|
+
const textEncoder = new TextEncoder();
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Media types the store infers from a pathname extension. The store knows many more; a pathname
|
|
183
|
+
* without one of these falls back to the request's `Content-Type`.
|
|
184
|
+
*/
|
|
185
|
+
const extensionMediaTypes: Readonly<Record<string, string>> = {
|
|
186
|
+
aac: "audio/x-aac",
|
|
187
|
+
avif: "image/avif",
|
|
188
|
+
bin: "application/octet-stream",
|
|
189
|
+
bmp: "image/bmp",
|
|
190
|
+
css: "text/css",
|
|
191
|
+
csv: "text/csv",
|
|
192
|
+
gif: "image/gif",
|
|
193
|
+
gz: "application/gzip",
|
|
194
|
+
htm: "text/html",
|
|
195
|
+
html: "text/html",
|
|
196
|
+
ico: "image/vnd.microsoft.icon",
|
|
197
|
+
jpeg: "image/jpeg",
|
|
198
|
+
jpg: "image/jpeg",
|
|
199
|
+
heic: "image/heic",
|
|
200
|
+
js: "application/javascript",
|
|
201
|
+
json: "application/json",
|
|
202
|
+
m4a: "audio/mp4",
|
|
203
|
+
md: "text/markdown",
|
|
204
|
+
mjs: "application/javascript",
|
|
205
|
+
mov: "video/quicktime",
|
|
206
|
+
mp3: "audio/mpeg",
|
|
207
|
+
mp4: "video/mp4",
|
|
208
|
+
oga: "audio/ogg",
|
|
209
|
+
ogg: "audio/ogg",
|
|
210
|
+
ogv: "video/ogg",
|
|
211
|
+
otf: "font/otf",
|
|
212
|
+
pdf: "application/pdf",
|
|
213
|
+
png: "image/png",
|
|
214
|
+
svg: "image/svg+xml",
|
|
215
|
+
tar: "application/x-tar",
|
|
216
|
+
tif: "image/tiff",
|
|
217
|
+
tiff: "image/tiff",
|
|
218
|
+
ttf: "font/ttf",
|
|
219
|
+
txt: "text/plain",
|
|
220
|
+
wasm: "application/wasm",
|
|
221
|
+
wav: "audio/wav",
|
|
222
|
+
webm: "video/webm",
|
|
223
|
+
webp: "image/webp",
|
|
224
|
+
woff: "font/woff",
|
|
225
|
+
woff2: "font/woff2",
|
|
226
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
227
|
+
xml: "application/xml",
|
|
228
|
+
yaml: "text/yaml",
|
|
229
|
+
yml: "text/yaml",
|
|
230
|
+
zip: "application/zip",
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const imageFormatMediaType: Record<ImageFormat, string> = {
|
|
234
|
+
jpeg: "image/jpeg",
|
|
235
|
+
png: "image/png",
|
|
236
|
+
webp: "image/webp",
|
|
237
|
+
avif: "image/avif",
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const lastSegment = (pathname: string): string => pathname.slice(pathname.lastIndexOf("/") + 1);
|
|
241
|
+
|
|
242
|
+
/** Index of the extension's dot in the last segment, or -1; a leading dot is not an extension. */
|
|
243
|
+
const extensionDot = (pathname: string): number => {
|
|
244
|
+
const segmentStart = pathname.lastIndexOf("/") + 1;
|
|
245
|
+
const dot = pathname.lastIndexOf(".");
|
|
246
|
+
return dot > segmentStart ? dot : -1;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const extensionMediaType = (pathname: string): string | undefined => {
|
|
250
|
+
const dot = extensionDot(pathname);
|
|
251
|
+
return dot === -1 ? undefined : extensionMediaTypes[pathname.slice(dot + 1).toLowerCase()];
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const octetStream = "application/octet-stream";
|
|
255
|
+
|
|
256
|
+
/** The `Content-Type` `fetch` sends for a body: strings are text, typed `Blob`s keep their type. */
|
|
257
|
+
const requestContentTypeOf = (body: unknown): string =>
|
|
258
|
+
typeof body === "string"
|
|
259
|
+
? "text/plain;charset=UTF-8"
|
|
260
|
+
: isBlob(body) && body.type !== ""
|
|
261
|
+
? body.type
|
|
262
|
+
: octetStream;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Media types the store serves `inline`, compared exactly as stored: `text/plain` is inline but
|
|
266
|
+
* `text/plain; charset=utf-8` is not. Any `image/`, `audio/` or `video/` type is inline too.
|
|
267
|
+
*/
|
|
268
|
+
const inlineMediaTypes: ReadonlySet<string> = new Set([
|
|
269
|
+
"application/json",
|
|
270
|
+
"application/pdf",
|
|
271
|
+
"text/plain",
|
|
272
|
+
"text/xml",
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
const isInline = (contentType: string): boolean =>
|
|
276
|
+
inlineMediaTypes.has(contentType) || /^(image|audio|video)\//.test(contentType);
|
|
277
|
+
|
|
278
|
+
const contentDispositionOf = (contentType: string, pathname: string): string =>
|
|
279
|
+
`${isInline(contentType) ? "inline" : "attachment"}; filename="${encodeURIComponent(lastSegment(pathname))}"`;
|
|
280
|
+
|
|
281
|
+
/** The store serves every blob as `public` for caches, private ones included. */
|
|
282
|
+
const cacheControlOf = (seconds: number): string => `public, max-age=${seconds}`;
|
|
283
|
+
|
|
284
|
+
/** ETags compare without their quotes or weak marker, as `If-Match` values are often sent bare. */
|
|
285
|
+
const sameEtag = (a: string, b: string): boolean => {
|
|
286
|
+
const bare = (etag: string) => etag.replace(/^W\//, "").replace(/^"(.*)"$/, "$1");
|
|
287
|
+
return bare(a) === bare(b);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const concatBytes = (chunks: ReadonlyArray<Uint8Array>): Uint8Array => {
|
|
291
|
+
const result = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
|
|
292
|
+
let offset = 0;
|
|
293
|
+
for (const chunk of chunks) {
|
|
294
|
+
result.set(chunk, offset);
|
|
295
|
+
offset += chunk.length;
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/** Read a body completely; a stream's failures and requirements become the caller's. */
|
|
301
|
+
const readBody = <E, R>(body: BlobBody<E, R>): Effect.Effect<Uint8Array, E, R> =>
|
|
302
|
+
Effect.map(Stream.runCollect(bodyStream(body)), concatBytes);
|
|
303
|
+
|
|
304
|
+
const toBytes = (body: string | Uint8Array): Uint8Array =>
|
|
305
|
+
typeof body === "string" ? textEncoder.encode(body) : body.slice();
|
|
306
|
+
|
|
307
|
+
const isBlob = (body: unknown): body is globalThis.Blob =>
|
|
308
|
+
typeof globalThis.Blob !== "undefined" && body instanceof globalThis.Blob;
|
|
309
|
+
|
|
310
|
+
const locatorString = (locator: BlobLocator): string =>
|
|
311
|
+
typeof locator === "string" ? locator : locator.href;
|
|
312
|
+
|
|
313
|
+
const badRequest = (operation: string, message: string) =>
|
|
314
|
+
new VercelApiError({
|
|
315
|
+
operation,
|
|
316
|
+
errorCode: Option.some("bad_request"),
|
|
317
|
+
reason: new ApiBadRequest({ message }),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/** The store's answer to a write over an existing blob without `allowOverwrite`. */
|
|
321
|
+
const blobAlreadyExistsMessage =
|
|
322
|
+
"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";
|
|
323
|
+
|
|
324
|
+
/** The store's answer to a write whose access differs from the store's. */
|
|
325
|
+
const wrongAccessMessage = (store: BlobAccess): string =>
|
|
326
|
+
store === "private"
|
|
327
|
+
? "Cannot use public access on a private store. The store is configured with private access."
|
|
328
|
+
: "Cannot use private access on a public store. The store must be configured with private access.";
|
|
329
|
+
|
|
330
|
+
const uploadNotFound = (operation: string) =>
|
|
331
|
+
new VercelApiError({
|
|
332
|
+
operation,
|
|
333
|
+
errorCode: Option.some("not_found"),
|
|
334
|
+
reason: new ApiUnexpectedResponse({
|
|
335
|
+
message: "The multipart upload does not exist",
|
|
336
|
+
status: 404,
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/** A defect carrying the SDK's `BlobError` message, as `BlobStoreLive` raises for invalid input. */
|
|
341
|
+
const invalidInput = (message: string) => Effect.die(new Error(`Vercel Blob: ${message}`));
|
|
342
|
+
|
|
343
|
+
const notBlobStoreUrl =
|
|
344
|
+
"Invalid URL: the URL does not point to a Vercel Blob store. Use a pathname instead, see https://vercel.com/docs/vercel-blob";
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Inspects and scripts the in-memory store behind {@link TestBlobStore.layer}.
|
|
348
|
+
*
|
|
349
|
+
* Time follows `Clock` and random suffixes follow `Random`, so `TestClock` and `Random.withSeed`
|
|
350
|
+
* make results deterministic.
|
|
351
|
+
*/
|
|
352
|
+
export class TestBlobStore extends Context.Service<
|
|
353
|
+
TestBlobStore,
|
|
354
|
+
{
|
|
355
|
+
/** Every `BlobStore` call so far, in order, including calls that failed. */
|
|
356
|
+
readonly calls: Effect.Effect<ReadonlyArray<TestBlobStoreCall>>;
|
|
357
|
+
|
|
358
|
+
/** Every stored blob, in pathname order. */
|
|
359
|
+
readonly blobs: Effect.Effect<ReadonlyArray<TestBlob>>;
|
|
360
|
+
|
|
361
|
+
/** The blob at a final pathname (including any random suffix). */
|
|
362
|
+
readonly blob: (pathname: string) => Effect.Effect<Option.Option<TestBlob>>;
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Store a blob directly, replacing any blob at `pathname`, without recording a call or
|
|
366
|
+
* consuming a scripted failure.
|
|
367
|
+
*/
|
|
368
|
+
readonly seed: (
|
|
369
|
+
pathname: string,
|
|
370
|
+
body: string | Uint8Array,
|
|
371
|
+
options?: TestBlobSeedOptions,
|
|
372
|
+
) => Effect.Effect<TestBlob>;
|
|
373
|
+
|
|
374
|
+
/** Make the next call to `operation` fail with `error` instead of running. */
|
|
375
|
+
readonly failNext: <Op extends TestBlobStoreOperation>(
|
|
376
|
+
operation: Op,
|
|
377
|
+
error: TestBlobStoreOperationError[Op],
|
|
378
|
+
) => Effect.Effect<void>;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Content the store fetches for a `putImage` source URL outside the store. Blob URLs of this
|
|
382
|
+
* store resolve to the stored blob; any other unserved URL fails with `ApiBadRequest`.
|
|
383
|
+
*/
|
|
384
|
+
readonly serveUrl: (
|
|
385
|
+
url: string | URL,
|
|
386
|
+
body: string | Uint8Array,
|
|
387
|
+
contentType?: string,
|
|
388
|
+
) => Effect.Effect<void>;
|
|
389
|
+
}
|
|
390
|
+
>()("@effect-vercel/blob/TestBlobStore") {
|
|
391
|
+
/**
|
|
392
|
+
* An in-memory `BlobStore` with the store's write, read, listing and multipart semantics, plus
|
|
393
|
+
* the {@link TestBlobStore} controls. Each build of the layer holds its own empty store.
|
|
394
|
+
*
|
|
395
|
+
* Behavior checked against real stores by `test/live` (`pnpm test:live`):
|
|
396
|
+
*
|
|
397
|
+
* - The store has one access, `public` unless set with {@link TestBlobStore.layerWith}; writes
|
|
398
|
+
* with the other access fail with `VercelApiError` / `ApiBadRequest` (`bad_request`).
|
|
399
|
+
* - Writes refuse an existing pathname with `ApiBadRequest` unless `allowOverwrite` is set,
|
|
400
|
+
* except `copy`, which always replaces. `ifMatch` replaces only a blob with that ETag and
|
|
401
|
+
* otherwise fails with `BlobPreconditionFailed`, also when nothing is stored there. Copying or
|
|
402
|
+
* renaming a missing blob fails with `BlobNotFound`.
|
|
403
|
+
* - Every write gets a new ETag and `uploadedAt` from `Clock`. `addRandomSuffix` inserts 30
|
|
404
|
+
* characters from `Random` before the extension.
|
|
405
|
+
* - Media types come from the option, then common extensions, then the request: text for a
|
|
406
|
+
* string body, the type of a typed `Blob`, `application/octet-stream` otherwise. Images,
|
|
407
|
+
* audio, video, `text/plain`, `text/xml`, `application/json` and `application/pdf` are served
|
|
408
|
+
* `inline`, anything else as `attachment`, with a percent-encoded filename. Cache-Control is
|
|
409
|
+
* `public, max-age=<seconds>` for every blob, one month by default.
|
|
410
|
+
* - URLs are `https://<storeId>.<access>.blob.vercel-storage.com/<pathname>`. A URL locator
|
|
411
|
+
* addresses a blob only with this store's host and the blob's access; `get` with a pathname
|
|
412
|
+
* finds it only under the given `access`.
|
|
413
|
+
* - `list` orders by pathname, with opaque cursors. `putImage` stores the source unchanged.
|
|
414
|
+
* Multipart uploads assemble their parts in part-number order; part sizes are not checked.
|
|
415
|
+
* - Progress reports 0 % before the body is read and 100 % once stored.
|
|
416
|
+
*/
|
|
417
|
+
static readonly layer: Layer.Layer<BlobStore | TestBlobStore> = TestBlobStore.layerWith({});
|
|
418
|
+
|
|
419
|
+
/** {@link TestBlobStore.layer} with a chosen store id or access. */
|
|
420
|
+
static layerWith(options: TestBlobStoreOptions): Layer.Layer<BlobStore | TestBlobStore> {
|
|
421
|
+
return Layer.effectContext(Effect.sync(() => buildTestBlobStore(options)));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function buildTestBlobStore(
|
|
426
|
+
options: TestBlobStoreOptions,
|
|
427
|
+
): Context.Context<BlobStore | TestBlobStore> {
|
|
428
|
+
const storeId = options.storeId ?? defaultStoreId;
|
|
429
|
+
const storeAccess = options.access ?? "public";
|
|
430
|
+
const storeHost = (access: BlobAccess) => defaultBlobBaseUrl(storeId, access).hostname;
|
|
431
|
+
const stored = new Map<string, StoredBlob>();
|
|
432
|
+
const uploads = new Map<string, PendingUpload>();
|
|
433
|
+
const remotes = new Map<string, RemoteSource>();
|
|
434
|
+
const recorded: Array<TestBlobStoreCall> = [];
|
|
435
|
+
const scripted = new Map<TestBlobStoreOperation, Array<unknown>>();
|
|
436
|
+
let sequence = 0;
|
|
437
|
+
|
|
438
|
+
const nextEtag = () => ETag.make(`"etag-${++sequence}"`);
|
|
439
|
+
const urlOf = (pathname: string, access: BlobAccess) =>
|
|
440
|
+
blobUrl(defaultBlobBaseUrl(storeId, access), pathname);
|
|
441
|
+
|
|
442
|
+
const view = (blob: StoredBlob): TestBlob => {
|
|
443
|
+
const url = urlOf(blob.pathname, blob.access);
|
|
444
|
+
return {
|
|
445
|
+
pathname: blob.pathname,
|
|
446
|
+
access: blob.access,
|
|
447
|
+
url,
|
|
448
|
+
downloadUrl: downloadUrl(url),
|
|
449
|
+
size: blob.body.length,
|
|
450
|
+
uploadedAt: blob.uploadedAt,
|
|
451
|
+
contentType: blob.contentType,
|
|
452
|
+
contentDisposition: contentDispositionOf(blob.contentType, blob.pathname),
|
|
453
|
+
cacheControl: cacheControlOf(blob.cacheControlMaxAge),
|
|
454
|
+
cacheControlMaxAge: blob.cacheControlMaxAge,
|
|
455
|
+
etag: blob.etag,
|
|
456
|
+
body: blob.body.slice(),
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const putResult = (blob: StoredBlob) => {
|
|
461
|
+
const { url, downloadUrl, pathname, contentType, contentDisposition, etag } = view(blob);
|
|
462
|
+
return new PutBlobResult({ url, downloadUrl, pathname, contentType, contentDisposition, etag });
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const call = <Op extends TestBlobStoreOperation, A, E, R>(
|
|
466
|
+
entry: Omit<TestBlobStoreCall, "at"> & { readonly operation: Op },
|
|
467
|
+
run: Effect.Effect<A, E, R>,
|
|
468
|
+
): Effect.Effect<A, E | TestBlobStoreOperationError[Op], R> =>
|
|
469
|
+
Effect.flatMap(
|
|
470
|
+
Clock.currentTimeMillis,
|
|
471
|
+
(now): Effect.Effect<A, E | TestBlobStoreOperationError[Op], R> => {
|
|
472
|
+
recorded.push({ ...entry, at: now });
|
|
473
|
+
const failure = scripted.get(entry.operation)?.shift();
|
|
474
|
+
if (failure !== undefined) {
|
|
475
|
+
return Effect.fail(failure as TestBlobStoreOperationError[Op]);
|
|
476
|
+
}
|
|
477
|
+
return run;
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const suffixed = Effect.fnUntraced(function* (pathname: string) {
|
|
482
|
+
let suffix = "";
|
|
483
|
+
for (let index = 0; index < suffixLength; index++) {
|
|
484
|
+
suffix += suffixAlphabet[yield* Random.nextIntBetween(0, suffixAlphabet.length - 1)];
|
|
485
|
+
}
|
|
486
|
+
const dot = extensionDot(pathname);
|
|
487
|
+
return dot === -1
|
|
488
|
+
? `${pathname}-${suffix}`
|
|
489
|
+
: `${pathname.slice(0, dot)}-${suffix}${pathname.slice(dot)}`;
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
/** Whether a write with `access` may replace what is stored at `pathname` now. */
|
|
493
|
+
const checkWrite = (
|
|
494
|
+
operation: string,
|
|
495
|
+
pathname: string,
|
|
496
|
+
access: BlobAccess,
|
|
497
|
+
allowOverwrite: boolean,
|
|
498
|
+
ifMatch: string | undefined,
|
|
499
|
+
): Effect.Effect<void, VercelApiError | BlobPreconditionFailed> => {
|
|
500
|
+
if (access !== storeAccess) {
|
|
501
|
+
return Effect.fail(badRequest(operation, wrongAccessMessage(storeAccess)));
|
|
502
|
+
}
|
|
503
|
+
const existing = stored.get(pathname);
|
|
504
|
+
if (ifMatch !== undefined) {
|
|
505
|
+
return existing !== undefined && sameEtag(existing.etag, ifMatch)
|
|
506
|
+
? Effect.void
|
|
507
|
+
: Effect.fail(new BlobPreconditionFailed());
|
|
508
|
+
}
|
|
509
|
+
return existing !== undefined && !allowOverwrite
|
|
510
|
+
? Effect.fail(badRequest(operation, blobAlreadyExistsMessage))
|
|
511
|
+
: Effect.void;
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
/** Check and store in one step, so concurrent writes cannot interleave between the two. */
|
|
515
|
+
const commit = (
|
|
516
|
+
input: Omit<WriteInput, "addRandomSuffix">,
|
|
517
|
+
uploadedAt: DateTime.Utc,
|
|
518
|
+
): Effect.Effect<StoredBlob, VercelApiError | BlobPreconditionFailed> =>
|
|
519
|
+
Effect.suspend(() =>
|
|
520
|
+
Effect.map(
|
|
521
|
+
checkWrite(
|
|
522
|
+
input.operation,
|
|
523
|
+
input.pathname,
|
|
524
|
+
input.access,
|
|
525
|
+
input.allowOverwrite,
|
|
526
|
+
input.ifMatch,
|
|
527
|
+
),
|
|
528
|
+
() => {
|
|
529
|
+
const blob: StoredBlob = {
|
|
530
|
+
pathname: input.pathname,
|
|
531
|
+
access: input.access,
|
|
532
|
+
body: input.body,
|
|
533
|
+
contentType:
|
|
534
|
+
input.contentType || (extensionMediaType(input.pathname) ?? input.requestContentType),
|
|
535
|
+
cacheControlMaxAge: input.cacheControlMaxAge ?? oneMonthSeconds,
|
|
536
|
+
etag: nextEtag(),
|
|
537
|
+
uploadedAt,
|
|
538
|
+
};
|
|
539
|
+
stored.set(blob.pathname, blob);
|
|
540
|
+
return blob;
|
|
541
|
+
},
|
|
542
|
+
),
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const write = Effect.fnUntraced(function* (input: WriteInput) {
|
|
546
|
+
const pathname = input.addRandomSuffix ? yield* suffixed(input.pathname) : input.pathname;
|
|
547
|
+
const uploadedAt = yield* DateTime.now;
|
|
548
|
+
return yield* commit({ ...input, pathname }, uploadedAt);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const maxAgeSeconds = (maxAge: Duration.Input | undefined) =>
|
|
552
|
+
maxAge === undefined ? undefined : cacheControlMaxAgeSeconds(maxAge);
|
|
553
|
+
|
|
554
|
+
/** The pathname a locator names in this store, and the access its URL requires. */
|
|
555
|
+
const locate = (
|
|
556
|
+
locator: BlobLocator,
|
|
557
|
+
): Option.Option<{ readonly pathname: string; readonly access: Option.Option<BlobAccess> }> => {
|
|
558
|
+
if (typeof locator === "string" && !isUrl(locator)) {
|
|
559
|
+
return Option.some({ pathname: locator, access: Option.none() });
|
|
560
|
+
}
|
|
561
|
+
let url: URL;
|
|
562
|
+
try {
|
|
563
|
+
url = new URL(locatorString(locator));
|
|
564
|
+
} catch {
|
|
565
|
+
return Option.none();
|
|
566
|
+
}
|
|
567
|
+
const access = (["public", "private"] as const).find(
|
|
568
|
+
(candidate) => url.hostname === storeHost(candidate),
|
|
569
|
+
);
|
|
570
|
+
if (access === undefined) return Option.none();
|
|
571
|
+
try {
|
|
572
|
+
return Option.some({
|
|
573
|
+
pathname: decodeURIComponent(url.pathname.slice(1)),
|
|
574
|
+
access: Option.some(access),
|
|
575
|
+
});
|
|
576
|
+
} catch {
|
|
577
|
+
return Option.none();
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const find = (locator: BlobLocator, access?: BlobAccess): Option.Option<StoredBlob> =>
|
|
582
|
+
Option.flatMap(locate(locator), (located) => {
|
|
583
|
+
const blob = stored.get(located.pathname);
|
|
584
|
+
const required = Option.getOrElse(located.access, () => access);
|
|
585
|
+
if (blob === undefined || (required !== undefined && blob.access !== required)) {
|
|
586
|
+
return Option.none();
|
|
587
|
+
}
|
|
588
|
+
return Option.some(blob);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const progressed = <A extends { readonly body: Uint8Array }, E, R>(
|
|
592
|
+
onUploadProgress: PutOptions["onUploadProgress"],
|
|
593
|
+
total: number,
|
|
594
|
+
run: Effect.Effect<A, E, R>,
|
|
595
|
+
): Effect.Effect<A, E, R> =>
|
|
596
|
+
onUploadProgress === undefined
|
|
597
|
+
? run
|
|
598
|
+
: Effect.gen(function* () {
|
|
599
|
+
yield* onUploadProgress({ loaded: 0, total, percentage: 0 });
|
|
600
|
+
const result = yield* run;
|
|
601
|
+
const loaded = result.body.length;
|
|
602
|
+
yield* onUploadProgress({ loaded, total: loaded, percentage: 100 });
|
|
603
|
+
return result;
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const put = Effect.fn("BlobStore.put")(function* <E = never, R = never>(
|
|
607
|
+
pathname: BlobPathname,
|
|
608
|
+
body: BlobBody<E, R>,
|
|
609
|
+
options: PutOptions,
|
|
610
|
+
): Effect.fn.Return<PutBlobResult, BlobWriteError | E, R> {
|
|
611
|
+
const operation = "BlobStore.put";
|
|
612
|
+
const total = bodyLength(body);
|
|
613
|
+
const maximum = options.maximumSizeInBytes;
|
|
614
|
+
const run = Effect.gen(function* () {
|
|
615
|
+
if (
|
|
616
|
+
options.multipart === true &&
|
|
617
|
+
maximum !== undefined &&
|
|
618
|
+
!Stream.isStream(body) &&
|
|
619
|
+
total > maximum
|
|
620
|
+
) {
|
|
621
|
+
return yield* new BlobUploadRejected({
|
|
622
|
+
reason: new FileTooLarge({
|
|
623
|
+
message: `Body size of ${total} bytes exceeds the maximum allowed size of ${maximum} bytes`,
|
|
624
|
+
}),
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
return yield* progressed(
|
|
628
|
+
options.onUploadProgress,
|
|
629
|
+
total,
|
|
630
|
+
Effect.flatMap(readBody(body), (bytes) =>
|
|
631
|
+
write({
|
|
632
|
+
operation,
|
|
633
|
+
pathname,
|
|
634
|
+
addRandomSuffix: options.addRandomSuffix ?? false,
|
|
635
|
+
access: options.access,
|
|
636
|
+
body: bytes,
|
|
637
|
+
contentType: options.contentType,
|
|
638
|
+
requestContentType:
|
|
639
|
+
options.multipart === true ? octetStream : requestContentTypeOf(body),
|
|
640
|
+
cacheControlMaxAge: maxAgeSeconds(options.cacheControlMaxAge),
|
|
641
|
+
allowOverwrite: options.allowOverwrite === true || options.ifMatch !== undefined,
|
|
642
|
+
ifMatch: options.ifMatch,
|
|
643
|
+
}),
|
|
644
|
+
),
|
|
645
|
+
);
|
|
646
|
+
});
|
|
647
|
+
return putResult(yield* call({ operation: "put", pathname }, run));
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
const imageSource = <E, R>(
|
|
651
|
+
source: BlobBody<E, R> | URL,
|
|
652
|
+
): Effect.Effect<RemoteSource, E | VercelApiError, R> => {
|
|
653
|
+
if (!(source instanceof URL)) {
|
|
654
|
+
return Effect.map(readBody(source), (body) => ({
|
|
655
|
+
body,
|
|
656
|
+
contentType:
|
|
657
|
+
isBlob(source) && source.type !== "" ? Option.some(source.type) : Option.none(),
|
|
658
|
+
}));
|
|
659
|
+
}
|
|
660
|
+
const blob = find(source);
|
|
661
|
+
if (Option.isSome(blob)) {
|
|
662
|
+
return Effect.succeed({
|
|
663
|
+
body: blob.value.body,
|
|
664
|
+
contentType: Option.some(blob.value.contentType),
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
const remote = remotes.get(source.href);
|
|
668
|
+
return remote === undefined
|
|
669
|
+
? Effect.fail(badRequest("BlobStore.putImage", `Failed to fetch the image at ${source.href}`))
|
|
670
|
+
: Effect.succeed(remote);
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const putImage = Effect.fn("BlobStore.putImage")(function* <E = never, R = never>(
|
|
674
|
+
pathname: BlobPathname,
|
|
675
|
+
source: BlobBody<E, R> | URL,
|
|
676
|
+
options: PutImageOptions,
|
|
677
|
+
): Effect.fn.Return<PutBlobResult, BlobWriteError | E, R> {
|
|
678
|
+
if (source instanceof URL && source.protocol !== "http:" && source.protocol !== "https:") {
|
|
679
|
+
return yield* invalidInput("the source URL must use the http(s) protocol");
|
|
680
|
+
}
|
|
681
|
+
const run = Effect.gen(function* () {
|
|
682
|
+
if (
|
|
683
|
+
isBlob(source) &&
|
|
684
|
+
source.type !== "" &&
|
|
685
|
+
source.type !== "application/octet-stream" &&
|
|
686
|
+
!source.type.startsWith("image/")
|
|
687
|
+
) {
|
|
688
|
+
return yield* new BlobUploadRejected({
|
|
689
|
+
reason: new ContentTypeNotAllowed({
|
|
690
|
+
message: `optimizeImage requires an image body, but the content type is "${source.type}"`,
|
|
691
|
+
}),
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
const format = options.optimizeImage.format;
|
|
695
|
+
return yield* progressed(
|
|
696
|
+
options.onUploadProgress,
|
|
697
|
+
source instanceof URL ? 0 : bodyLength(source),
|
|
698
|
+
Effect.flatMap(imageSource(source), (image) =>
|
|
699
|
+
write({
|
|
700
|
+
operation: "BlobStore.putImage",
|
|
701
|
+
pathname,
|
|
702
|
+
addRandomSuffix: options.addRandomSuffix ?? false,
|
|
703
|
+
access: options.access,
|
|
704
|
+
body: image.body,
|
|
705
|
+
contentType:
|
|
706
|
+
format === undefined
|
|
707
|
+
? Option.getOrUndefined(image.contentType)
|
|
708
|
+
: imageFormatMediaType[format],
|
|
709
|
+
requestContentType: octetStream,
|
|
710
|
+
cacheControlMaxAge: maxAgeSeconds(options.cacheControlMaxAge),
|
|
711
|
+
allowOverwrite: options.allowOverwrite === true || options.ifMatch !== undefined,
|
|
712
|
+
ifMatch: options.ifMatch,
|
|
713
|
+
}),
|
|
714
|
+
),
|
|
715
|
+
);
|
|
716
|
+
});
|
|
717
|
+
return putResult(yield* call({ operation: "putImage", pathname }, run));
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const head = Effect.fn("BlobStore.head")(function* (blob: BlobLocator) {
|
|
721
|
+
const locator = locatorString(blob);
|
|
722
|
+
const run = Effect.suspend(() =>
|
|
723
|
+
Option.match(find(blob), {
|
|
724
|
+
onNone: () => Effect.fail(new BlobNotFound({ locator })),
|
|
725
|
+
onSome: (found) => {
|
|
726
|
+
const current = view(found);
|
|
727
|
+
return Effect.succeed(
|
|
728
|
+
new BlobMetadata({
|
|
729
|
+
url: current.url,
|
|
730
|
+
downloadUrl: current.downloadUrl,
|
|
731
|
+
pathname: current.pathname,
|
|
732
|
+
size: current.size,
|
|
733
|
+
uploadedAt: current.uploadedAt,
|
|
734
|
+
contentType: current.contentType,
|
|
735
|
+
contentDisposition: current.contentDisposition,
|
|
736
|
+
cacheControl: current.cacheControl,
|
|
737
|
+
etag: current.etag,
|
|
738
|
+
}),
|
|
739
|
+
);
|
|
740
|
+
},
|
|
741
|
+
}),
|
|
742
|
+
);
|
|
743
|
+
return yield* call({ operation: "head", locators: [locator] }, run);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const get = Effect.fn("BlobStore.get")(function* (blob: BlobLocator, options: GetOptions) {
|
|
747
|
+
const locator = locatorString(blob);
|
|
748
|
+
if (locator === "") {
|
|
749
|
+
return yield* invalidInput("url or pathname is required");
|
|
750
|
+
}
|
|
751
|
+
if (isUrl(locator)) {
|
|
752
|
+
let url: URL | undefined;
|
|
753
|
+
try {
|
|
754
|
+
url = new URL(locator);
|
|
755
|
+
} catch {
|
|
756
|
+
return yield* invalidInput("Invalid URL: unable to parse the provided URL");
|
|
757
|
+
}
|
|
758
|
+
if (!url.hostname.endsWith(".blob.vercel-storage.com")) {
|
|
759
|
+
return yield* invalidInput(notBlobStoreUrl);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
const run = Effect.suspend(() =>
|
|
763
|
+
Option.match(find(blob, options.access), {
|
|
764
|
+
onNone: () => Effect.fail(new BlobNotFound({ locator })),
|
|
765
|
+
onSome: (found): Effect.Effect<GetBlobResult> => {
|
|
766
|
+
const current = view(found);
|
|
767
|
+
const validators: Record<string, string> = {
|
|
768
|
+
"cache-control": current.cacheControl,
|
|
769
|
+
"content-disposition": current.contentDisposition,
|
|
770
|
+
etag: current.etag,
|
|
771
|
+
"last-modified": DateTime.toDateUtc(current.uploadedAt).toUTCString(),
|
|
772
|
+
};
|
|
773
|
+
const fetched = new FetchedBlob({
|
|
774
|
+
url: current.url,
|
|
775
|
+
downloadUrl: current.downloadUrl,
|
|
776
|
+
pathname: current.pathname,
|
|
777
|
+
contentDisposition: current.contentDisposition,
|
|
778
|
+
cacheControl: current.cacheControl,
|
|
779
|
+
uploadedAt: current.uploadedAt,
|
|
780
|
+
etag: current.etag,
|
|
781
|
+
});
|
|
782
|
+
if (options.ifNoneMatch !== undefined && sameEtag(current.etag, options.ifNoneMatch)) {
|
|
783
|
+
return Effect.succeed(
|
|
784
|
+
GetBlobResult.NotModified({ blob: fetched, headers: validators }),
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
return Effect.succeed(
|
|
788
|
+
GetBlobResult.Found({
|
|
789
|
+
blob: fetched,
|
|
790
|
+
contentType: current.contentType,
|
|
791
|
+
size: current.size,
|
|
792
|
+
headers: {
|
|
793
|
+
...validators,
|
|
794
|
+
"content-length": String(current.size),
|
|
795
|
+
"content-type": current.contentType,
|
|
796
|
+
},
|
|
797
|
+
body: Stream.succeed(current.body),
|
|
798
|
+
}),
|
|
799
|
+
);
|
|
800
|
+
},
|
|
801
|
+
}),
|
|
802
|
+
);
|
|
803
|
+
return yield* call({ operation: "get", locators: [locator] }, run);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
type ListArgs = ListOptions & { readonly cursor?: string | undefined };
|
|
807
|
+
|
|
808
|
+
/** The page's entries: blobs, and in folded mode the folders directly under the prefix. */
|
|
809
|
+
const listEntries = (args: ListArgs) => {
|
|
810
|
+
const prefix = args.prefix ?? "";
|
|
811
|
+
const folded = args.mode === "folded";
|
|
812
|
+
const entries = new Map<string, Option.Option<StoredBlob>>();
|
|
813
|
+
for (const blob of stored.values()) {
|
|
814
|
+
if (!blob.pathname.startsWith(prefix)) continue;
|
|
815
|
+
const slash = folded ? blob.pathname.indexOf("/", prefix.length) : -1;
|
|
816
|
+
if (slash === -1) {
|
|
817
|
+
entries.set(blob.pathname, Option.some(blob));
|
|
818
|
+
} else {
|
|
819
|
+
entries.set(blob.pathname.slice(0, slash + 1), Option.none());
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
const listPage = (args: ListArgs) =>
|
|
826
|
+
Effect.sync(() => {
|
|
827
|
+
const limit = args.limit && args.limit > 0 ? Math.min(args.limit, 1000) : defaultListLimit;
|
|
828
|
+
const after = args.cursor;
|
|
829
|
+
const remaining = listEntries(args).filter(([key]) => after === undefined || key > after);
|
|
830
|
+
const page = remaining.slice(0, limit);
|
|
831
|
+
const hasMore = remaining.length > page.length;
|
|
832
|
+
return new ListPage({
|
|
833
|
+
blobs: page.flatMap(([, entry]) =>
|
|
834
|
+
Option.match(entry, {
|
|
835
|
+
onNone: () => [],
|
|
836
|
+
onSome: (blob) => {
|
|
837
|
+
const { url, downloadUrl, pathname, size, uploadedAt, etag } = view(blob);
|
|
838
|
+
return [new ListedBlob({ url, downloadUrl, pathname, size, uploadedAt, etag })];
|
|
839
|
+
},
|
|
840
|
+
}),
|
|
841
|
+
),
|
|
842
|
+
folders: page.flatMap(([key, entry]) => (Option.isNone(entry) ? [key] : [])),
|
|
843
|
+
cursor: hasMore ? Option.some(page[page.length - 1]![0]) : Option.none(),
|
|
844
|
+
hasMore,
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const list = Effect.fn("BlobStore.list")(function* (args: ListArgs = {}) {
|
|
849
|
+
return yield* call(
|
|
850
|
+
{
|
|
851
|
+
operation: "list",
|
|
852
|
+
...(args.prefix === undefined ? {} : { prefix: args.prefix }),
|
|
853
|
+
...(args.cursor === undefined ? {} : { cursor: args.cursor }),
|
|
854
|
+
},
|
|
855
|
+
listPage(args),
|
|
856
|
+
);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
const listAll = (args: ListOptions = {}) =>
|
|
860
|
+
Stream.paginate(undefined as string | undefined, (cursor) =>
|
|
861
|
+
Effect.map(list({ ...args, cursor }), (page) => {
|
|
862
|
+
const next = page.hasMore ? page.cursor : Option.none();
|
|
863
|
+
return [page.blobs, next] as const;
|
|
864
|
+
}),
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
const del = Effect.fn("BlobStore.del")(function* (
|
|
868
|
+
blob: BlobLocator,
|
|
869
|
+
options?: { readonly ifMatch?: ETag | undefined },
|
|
870
|
+
) {
|
|
871
|
+
const run = Effect.suspend((): Effect.Effect<void, VercelApiError | BlobPreconditionFailed> => {
|
|
872
|
+
const found = find(blob);
|
|
873
|
+
const ifMatch = options?.ifMatch;
|
|
874
|
+
if (ifMatch !== undefined && (Option.isNone(found) || !sameEtag(found.value.etag, ifMatch))) {
|
|
875
|
+
return Effect.fail(new BlobPreconditionFailed());
|
|
876
|
+
}
|
|
877
|
+
if (Option.isSome(found)) stored.delete(found.value.pathname);
|
|
878
|
+
return Effect.void;
|
|
879
|
+
});
|
|
880
|
+
return yield* call({ operation: "del", locators: [locatorString(blob)] }, run);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
const delMany = Effect.fn("BlobStore.delMany")(function* (blobs: ReadonlyArray<BlobLocator>) {
|
|
884
|
+
const run = Effect.sync(() => {
|
|
885
|
+
for (const blob of blobs) {
|
|
886
|
+
const found = find(blob);
|
|
887
|
+
if (Option.isSome(found)) stored.delete(found.value.pathname);
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
return yield* call({ operation: "delMany", locators: blobs.map(locatorString) }, run);
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
/** Copy `from` to `to` without inheriting its media type or cache lifetime. */
|
|
894
|
+
const copyBlob = (
|
|
895
|
+
operation: string,
|
|
896
|
+
from: BlobLocator,
|
|
897
|
+
to: string,
|
|
898
|
+
options: CreateBlobOptions,
|
|
899
|
+
allowOverwrite: boolean,
|
|
900
|
+
) =>
|
|
901
|
+
Effect.gen(function* () {
|
|
902
|
+
const source = find(from);
|
|
903
|
+
if (Option.isNone(source)) {
|
|
904
|
+
return yield* new BlobNotFound({ locator: locatorString(from) });
|
|
905
|
+
}
|
|
906
|
+
if (options.ifMatch !== undefined && !sameEtag(source.value.etag, options.ifMatch)) {
|
|
907
|
+
return yield* new BlobPreconditionFailed();
|
|
908
|
+
}
|
|
909
|
+
const copied = yield* write({
|
|
910
|
+
operation,
|
|
911
|
+
pathname: to,
|
|
912
|
+
addRandomSuffix: options.addRandomSuffix ?? false,
|
|
913
|
+
access: options.access,
|
|
914
|
+
body: source.value.body,
|
|
915
|
+
contentType: options.contentType,
|
|
916
|
+
requestContentType: octetStream,
|
|
917
|
+
cacheControlMaxAge: maxAgeSeconds(options.cacheControlMaxAge),
|
|
918
|
+
allowOverwrite,
|
|
919
|
+
ifMatch: undefined,
|
|
920
|
+
});
|
|
921
|
+
return { source: source.value, copied };
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const copy = Effect.fn("BlobStore.copy")(function* (
|
|
925
|
+
from: BlobLocator,
|
|
926
|
+
to: BlobPathname,
|
|
927
|
+
options: CreateBlobOptions,
|
|
928
|
+
) {
|
|
929
|
+
// The store replaces an existing blob at `to` whatever `allowOverwrite` says.
|
|
930
|
+
const run = Effect.map(copyBlob("BlobStore.copy", from, to, options, true), ({ copied }) =>
|
|
931
|
+
putResult(copied),
|
|
932
|
+
);
|
|
933
|
+
return yield* call({ operation: "copy", pathname: to, locators: [locatorString(from)] }, run);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
const rename = Effect.fn("BlobStore.rename")(function* (
|
|
937
|
+
from: BlobLocator,
|
|
938
|
+
to: BlobPathname,
|
|
939
|
+
options: CreateBlobOptions,
|
|
940
|
+
) {
|
|
941
|
+
const run = Effect.map(
|
|
942
|
+
copyBlob("BlobStore.rename", from, to, options, options.allowOverwrite === true),
|
|
943
|
+
({ source, copied }) => {
|
|
944
|
+
if (source.pathname !== copied.pathname && stored.get(source.pathname) === source) {
|
|
945
|
+
stored.delete(source.pathname);
|
|
946
|
+
}
|
|
947
|
+
return putResult(copied);
|
|
948
|
+
},
|
|
949
|
+
);
|
|
950
|
+
return yield* call({ operation: "rename", pathname: to, locators: [locatorString(from)] }, run);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
const createFolder = Effect.fn("BlobStore.createFolder")(function* (
|
|
954
|
+
pathname: BlobPathname,
|
|
955
|
+
options?: { readonly access?: BlobAccess | undefined },
|
|
956
|
+
) {
|
|
957
|
+
const folder = pathname.endsWith("/") ? pathname : `${pathname}/`;
|
|
958
|
+
const run = Effect.map(
|
|
959
|
+
write({
|
|
960
|
+
operation: "BlobStore.createFolder",
|
|
961
|
+
pathname: folder,
|
|
962
|
+
addRandomSuffix: false,
|
|
963
|
+
access: options?.access ?? "public",
|
|
964
|
+
body: new Uint8Array(0),
|
|
965
|
+
contentType: undefined,
|
|
966
|
+
requestContentType: octetStream,
|
|
967
|
+
cacheControlMaxAge: undefined,
|
|
968
|
+
allowOverwrite: false,
|
|
969
|
+
ifMatch: undefined,
|
|
970
|
+
}),
|
|
971
|
+
(blob) =>
|
|
972
|
+
new CreateFolderResult({ url: urlOf(blob.pathname, blob.access), pathname: folder }),
|
|
973
|
+
);
|
|
974
|
+
return yield* call({ operation: "createFolder", pathname }, run);
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
const createMultipartUpload = Effect.fn("BlobStore.createMultipartUpload")(function* (
|
|
978
|
+
pathname: BlobPathname,
|
|
979
|
+
options: CreateMultipartUploadOptions,
|
|
980
|
+
) {
|
|
981
|
+
const operation = "BlobStore.createMultipartUpload";
|
|
982
|
+
const run = Effect.gen(function* () {
|
|
983
|
+
const key = (options.addRandomSuffix ?? true) ? yield* suffixed(pathname) : pathname;
|
|
984
|
+
yield* checkWrite(
|
|
985
|
+
operation,
|
|
986
|
+
key,
|
|
987
|
+
options.access,
|
|
988
|
+
options.allowOverwrite === true || options.ifMatch !== undefined,
|
|
989
|
+
options.ifMatch,
|
|
990
|
+
);
|
|
991
|
+
const uploadId = `upload-${++sequence}`;
|
|
992
|
+
uploads.set(uploadId, { key, ifMatch: options.ifMatch, parts: new Map() });
|
|
993
|
+
const maxAge = maxAgeSeconds(options.cacheControlMaxAge);
|
|
994
|
+
return new MultipartUpload({
|
|
995
|
+
pathname,
|
|
996
|
+
key,
|
|
997
|
+
uploadId,
|
|
998
|
+
access: options.access,
|
|
999
|
+
...(options.addRandomSuffix === undefined
|
|
1000
|
+
? {}
|
|
1001
|
+
: { addRandomSuffix: options.addRandomSuffix }),
|
|
1002
|
+
...(options.ifMatch !== undefined
|
|
1003
|
+
? { allowOverwrite: true }
|
|
1004
|
+
: options.allowOverwrite === undefined
|
|
1005
|
+
? {}
|
|
1006
|
+
: { allowOverwrite: options.allowOverwrite }),
|
|
1007
|
+
...(options.contentType ? { contentType: options.contentType } : {}),
|
|
1008
|
+
...(maxAge === undefined ? {} : { cacheControlMaxAge: maxAge }),
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
return yield* call({ operation: "createMultipartUpload", pathname }, run);
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
const pendingUpload = (operation: string, upload: MultipartUploadType) =>
|
|
1015
|
+
Effect.suspend(() => {
|
|
1016
|
+
const pending = uploads.get(upload.uploadId);
|
|
1017
|
+
return pending === undefined || pending.key !== upload.key
|
|
1018
|
+
? Effect.fail(uploadNotFound(operation))
|
|
1019
|
+
: Effect.succeed(pending);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
const uploadPart = Effect.fn("BlobStore.uploadPart")(function* <E = never, R = never>(
|
|
1023
|
+
upload: MultipartUploadType,
|
|
1024
|
+
partNumber: PartNumber,
|
|
1025
|
+
body: BlobBody<E, R>,
|
|
1026
|
+
): Effect.fn.Return<UploadedPart, BlobWriteError | E, R> {
|
|
1027
|
+
const run = Effect.gen(function* () {
|
|
1028
|
+
yield* pendingUpload("BlobStore.uploadPart", upload);
|
|
1029
|
+
const bytes = yield* readBody(body);
|
|
1030
|
+
const pending = yield* pendingUpload("BlobStore.uploadPart", upload);
|
|
1031
|
+
const etag = `"part-${++sequence}"`;
|
|
1032
|
+
pending.parts.set(partNumber, { etag, bytes });
|
|
1033
|
+
return new UploadedPart({ etag, partNumber });
|
|
1034
|
+
});
|
|
1035
|
+
return yield* call({ operation: "uploadPart", pathname: upload.pathname, partNumber }, run);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
const completeMultipartUpload = Effect.fn("BlobStore.completeMultipartUpload")(function* (
|
|
1039
|
+
upload: MultipartUploadType,
|
|
1040
|
+
parts: ReadonlyArray<UploadedPart>,
|
|
1041
|
+
) {
|
|
1042
|
+
const operation = "BlobStore.completeMultipartUpload";
|
|
1043
|
+
const run = Effect.gen(function* () {
|
|
1044
|
+
const pending = yield* pendingUpload(operation, upload);
|
|
1045
|
+
const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
1046
|
+
const chunks: Array<Uint8Array> = [];
|
|
1047
|
+
for (const part of ordered) {
|
|
1048
|
+
const uploaded = pending.parts.get(part.partNumber);
|
|
1049
|
+
if (uploaded === undefined || !sameEtag(uploaded.etag, part.etag)) {
|
|
1050
|
+
return yield* badRequest(
|
|
1051
|
+
operation,
|
|
1052
|
+
"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.",
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
chunks.push(uploaded.bytes);
|
|
1056
|
+
}
|
|
1057
|
+
const uploadedAt = yield* DateTime.now;
|
|
1058
|
+
const blob = yield* commit(
|
|
1059
|
+
{
|
|
1060
|
+
operation,
|
|
1061
|
+
pathname: pending.key,
|
|
1062
|
+
access: upload.access,
|
|
1063
|
+
body: concatBytes(chunks),
|
|
1064
|
+
contentType: upload.contentType,
|
|
1065
|
+
requestContentType: octetStream,
|
|
1066
|
+
cacheControlMaxAge: upload.cacheControlMaxAge,
|
|
1067
|
+
allowOverwrite: upload.allowOverwrite === true || pending.ifMatch !== undefined,
|
|
1068
|
+
ifMatch: pending.ifMatch,
|
|
1069
|
+
},
|
|
1070
|
+
uploadedAt,
|
|
1071
|
+
);
|
|
1072
|
+
uploads.delete(upload.uploadId);
|
|
1073
|
+
return putResult(blob);
|
|
1074
|
+
});
|
|
1075
|
+
return yield* call({ operation: "completeMultipartUpload", pathname: upload.pathname }, run);
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
const api = BlobStore.of({
|
|
1079
|
+
put,
|
|
1080
|
+
putImage,
|
|
1081
|
+
head,
|
|
1082
|
+
get,
|
|
1083
|
+
list,
|
|
1084
|
+
listAll,
|
|
1085
|
+
del,
|
|
1086
|
+
delMany,
|
|
1087
|
+
copy,
|
|
1088
|
+
rename,
|
|
1089
|
+
createFolder,
|
|
1090
|
+
createMultipartUpload,
|
|
1091
|
+
uploadPart,
|
|
1092
|
+
completeMultipartUpload,
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
const control = TestBlobStore.of({
|
|
1096
|
+
calls: Effect.sync(() => [...recorded]),
|
|
1097
|
+
blobs: Effect.sync(() =>
|
|
1098
|
+
[...stored.values()]
|
|
1099
|
+
.sort((a, b) => (a.pathname < b.pathname ? -1 : a.pathname > b.pathname ? 1 : 0))
|
|
1100
|
+
.map(view),
|
|
1101
|
+
),
|
|
1102
|
+
blob: (pathname) =>
|
|
1103
|
+
Effect.sync(() => Option.map(Option.fromUndefinedOr(stored.get(pathname)), view)),
|
|
1104
|
+
seed: (pathname, body, seedOptions) =>
|
|
1105
|
+
Effect.map(DateTime.now, (uploadedAt) => {
|
|
1106
|
+
const blob: StoredBlob = {
|
|
1107
|
+
pathname,
|
|
1108
|
+
access: seedOptions?.access ?? storeAccess,
|
|
1109
|
+
body: toBytes(body),
|
|
1110
|
+
contentType:
|
|
1111
|
+
seedOptions?.contentType ||
|
|
1112
|
+
(extensionMediaType(pathname) ?? requestContentTypeOf(body)),
|
|
1113
|
+
cacheControlMaxAge: maxAgeSeconds(seedOptions?.cacheControlMaxAge) ?? oneMonthSeconds,
|
|
1114
|
+
etag: nextEtag(),
|
|
1115
|
+
uploadedAt,
|
|
1116
|
+
};
|
|
1117
|
+
stored.set(pathname, blob);
|
|
1118
|
+
return view(blob);
|
|
1119
|
+
}),
|
|
1120
|
+
failNext: (operation, error) =>
|
|
1121
|
+
Effect.sync(() => {
|
|
1122
|
+
const queue = scripted.get(operation) ?? [];
|
|
1123
|
+
queue.push(error);
|
|
1124
|
+
scripted.set(operation, queue);
|
|
1125
|
+
}),
|
|
1126
|
+
serveUrl: (url, body, contentType) =>
|
|
1127
|
+
Effect.sync(() => {
|
|
1128
|
+
remotes.set(new URL(url).href, {
|
|
1129
|
+
body: toBytes(body),
|
|
1130
|
+
contentType: Option.fromUndefinedOr(contentType),
|
|
1131
|
+
});
|
|
1132
|
+
}),
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
return Context.make(BlobStore, api).pipe(Context.add(TestBlobStore, control));
|
|
1136
|
+
}
|