@panorama-industries/effect-vercel-blob 0.0.0 → 0.3.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 +33 -1
- 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 +26 -0
- package/dist/implementation/blob-live.d.ts.map +1 -0
- package/dist/implementation/blob-live.js +26 -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 +58 -4
- 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 +55 -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,387 @@
|
|
|
1
|
+
import type { Array } from "effect";
|
|
2
|
+
|
|
3
|
+
import { Config, Effect, Layer, Option, Predicate, Redacted, Schema } from "effect";
|
|
4
|
+
import { HttpClient, HttpClientRequest } from "effect/http";
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
BlobClientError,
|
|
8
|
+
ClientUploadOptions,
|
|
9
|
+
UploadRouteOptions,
|
|
10
|
+
} from "../contracts/blob-client.ts";
|
|
11
|
+
import type {
|
|
12
|
+
BlobAccess,
|
|
13
|
+
BlobBody,
|
|
14
|
+
BlobPathname,
|
|
15
|
+
ClientToken,
|
|
16
|
+
PartNumber,
|
|
17
|
+
PutBlobResult,
|
|
18
|
+
UploadedPart,
|
|
19
|
+
} from "../contracts/blob-domain.ts";
|
|
20
|
+
import type { BlobDomainErrorTag } from "../internal/blob-errors.ts";
|
|
21
|
+
import type { BlobRequestSettings } from "../internal/blob-request.ts";
|
|
22
|
+
|
|
23
|
+
import { BlobClient } from "../contracts/blob-client.ts";
|
|
24
|
+
import {
|
|
25
|
+
ClientToken as ClientTokenSchema,
|
|
26
|
+
MultipartUpload,
|
|
27
|
+
PresignedUrlPayload,
|
|
28
|
+
} from "../contracts/blob-domain.ts";
|
|
29
|
+
import { BlobUploadAuthorizationFailed } from "../contracts/blob-errors.ts";
|
|
30
|
+
import { BlobRequestAuth, blobRequester } from "../internal/blob-request.ts";
|
|
31
|
+
import { requestEnv } from "../internal/env.ts";
|
|
32
|
+
import * as Multipart from "../internal/multipart.ts";
|
|
33
|
+
import { storeIdFromClientToken, storeIdFromDelegationToken } from "../internal/store-id.ts";
|
|
34
|
+
import { PutBlobResponse } from "../internal/wire.ts";
|
|
35
|
+
import { allowedWriteOptions, writeHeaders } from "../internal/write-headers.ts";
|
|
36
|
+
|
|
37
|
+
/** Values that replace what {@link BlobClientLive} reads from the environment. */
|
|
38
|
+
export interface BlobClientOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Blob API base URL; replaces `VERCEL_BLOB_API_URL` and `NEXT_PUBLIC_VERCEL_BLOB_API_URL`, for an
|
|
41
|
+
* emulator or a test server.
|
|
42
|
+
*/
|
|
43
|
+
readonly apiUrl?: URL | string | undefined;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extra headers for every Blob API request, merged over the environment's. Headers for the
|
|
47
|
+
* upload route are given per upload instead.
|
|
48
|
+
*/
|
|
49
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
50
|
+
|
|
51
|
+
/** Retries after the first attempt; replaces `VERCEL_BLOB_RETRIES` (default 10). */
|
|
52
|
+
readonly retries?: number | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The SDK wraps a single upload's `onUploadProgress` in `throttle(…, 100)`. */
|
|
56
|
+
const singleUploadProgressInterval = "100 millis";
|
|
57
|
+
|
|
58
|
+
/** The domain errors {@link BlobClientError} declares; other error codes become `VercelApiError`. */
|
|
59
|
+
const clientDomainErrors = [
|
|
60
|
+
"BlobStoreUnavailable",
|
|
61
|
+
"BlobUploadRejected",
|
|
62
|
+
"BlobPreconditionFailed",
|
|
63
|
+
"BlobClientTokenNotAllowed",
|
|
64
|
+
] as const satisfies ReadonlyArray<BlobDomainErrorTag>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The request settings of the browser client, read like the SDK's `requestApi` reads them. There
|
|
68
|
+
* is no server `BlobConfig` here: credentials come with each upload.
|
|
69
|
+
*/
|
|
70
|
+
const resolveClientSettings = Effect.fnUntraced(function* (
|
|
71
|
+
options: BlobClientOptions,
|
|
72
|
+
httpClient: HttpClient.HttpClient,
|
|
73
|
+
): Effect.fn.Return<BlobRequestSettings, Config.ConfigError> {
|
|
74
|
+
const { apiUrl, apiVersion, retries, sendContentLength, headers } = yield* requestEnv(options);
|
|
75
|
+
return { httpClient, apiUrl, apiVersion, retries, sendContentLength, headers };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Event types the browser posts to the upload route, as the SDK's `EventTypes`. */
|
|
79
|
+
type UploadRouteEventType = "blob.generate-client-token" | "blob.generate-presigned-url";
|
|
80
|
+
|
|
81
|
+
/** The SDK's messages for a route that cannot be reached or answers with a non-2xx status. */
|
|
82
|
+
const routeFailedMessages = {
|
|
83
|
+
"blob.generate-client-token": "Failed to retrieve the client token",
|
|
84
|
+
"blob.generate-presigned-url": "Failed to retrieve the presigned URL",
|
|
85
|
+
} as const satisfies Record<UploadRouteEventType, string>;
|
|
86
|
+
|
|
87
|
+
/** The SDK's messages for a 2xx route response whose body cannot be read. */
|
|
88
|
+
const routeBodyMessages = {
|
|
89
|
+
"blob.generate-client-token": "Failed to retrieve the client token",
|
|
90
|
+
"blob.generate-presigned-url": "Failed to retrieve the presigned URL",
|
|
91
|
+
} as const satisfies Record<UploadRouteEventType, string>;
|
|
92
|
+
|
|
93
|
+
const ClientTokenResponse = Schema.Struct({ clientToken: ClientTokenSchema });
|
|
94
|
+
|
|
95
|
+
const PresignedUrlResponse = Schema.Struct({
|
|
96
|
+
presignedUrlPayload: Schema.optionalKey(Schema.NullOr(PresignedUrlPayload)),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const decodeAbsoluteUrl = Schema.decodeUnknownOption(Schema.URLFromString);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The upload route's absolute URL, as the SDK's `isAbsoluteUrl`/`toAbsoluteUrl`: an absolute URL
|
|
103
|
+
* is used as given, a relative one resolves against `globalThis.location.href` (pages and web
|
|
104
|
+
* workers). A relative URL where there is no `location`, such as on a server, is a defect.
|
|
105
|
+
*/
|
|
106
|
+
const resolveUploadRouteUrl = (handleUploadUrl: string): Effect.Effect<string> =>
|
|
107
|
+
Effect.suspend(() => {
|
|
108
|
+
if (Option.isSome(decodeAbsoluteUrl(handleUploadUrl))) {
|
|
109
|
+
return Effect.succeed(handleUploadUrl);
|
|
110
|
+
}
|
|
111
|
+
const location: unknown = Reflect.get(globalThis, "location");
|
|
112
|
+
if (!Predicate.hasProperty(location, "href") || !Predicate.isString(location.href)) {
|
|
113
|
+
return Effect.die(
|
|
114
|
+
new Error(
|
|
115
|
+
`Cannot resolve the relative handleUploadUrl "${handleUploadUrl}": there is no ` +
|
|
116
|
+
"globalThis.location. Pass an absolute URL outside of browsers.",
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
const base = location.href;
|
|
121
|
+
return Effect.try({
|
|
122
|
+
try: () => new URL(handleUploadUrl, base).href,
|
|
123
|
+
catch: (cause) => cause,
|
|
124
|
+
}).pipe(Effect.orDie);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const withoutTracePropagation = Effect.provideService(HttpClient.TracerPropagationEnabled, false);
|
|
128
|
+
|
|
129
|
+
const clientTokenAuth = (token: ClientToken): BlobRequestAuth =>
|
|
130
|
+
BlobRequestAuth.Bearer({
|
|
131
|
+
token: Redacted.make(token),
|
|
132
|
+
// The SDK sends an empty store id when the token has none.
|
|
133
|
+
storeId: Option.getOrElse(storeIdFromClientToken(token), () => ""),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const clientWriteHeaders = (options: {
|
|
137
|
+
readonly access: BlobAccess;
|
|
138
|
+
readonly contentType?: string | undefined;
|
|
139
|
+
}): Record<string, string> =>
|
|
140
|
+
writeHeaders(allowedWriteOptions.client, {
|
|
141
|
+
access: options.access,
|
|
142
|
+
contentType: options.contentType,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const makeBlobClient = Effect.fnUntraced(function* (options: BlobClientOptions) {
|
|
146
|
+
const httpClient = yield* HttpClient.HttpClient;
|
|
147
|
+
const requester = blobRequester(yield* resolveClientSettings(options, httpClient));
|
|
148
|
+
|
|
149
|
+
/** The multipart engine with the client's write headers and domain errors, for one upload's auth. */
|
|
150
|
+
const multipartTarget = (
|
|
151
|
+
auth: BlobRequestAuth,
|
|
152
|
+
): Multipart.MultipartTarget<never, never, (typeof clientDomainErrors)[number]> => ({
|
|
153
|
+
requester,
|
|
154
|
+
auth: Effect.succeed(auth),
|
|
155
|
+
service: "BlobClient",
|
|
156
|
+
domainErrors: clientDomainErrors,
|
|
157
|
+
writeOptions: Multipart.clientMultipartWriteOptions,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/** POST the event to the upload route, as the SDK's `retrieveClientToken` does. */
|
|
161
|
+
const postUploadEvent = Effect.fnUntraced(function* (
|
|
162
|
+
type: UploadRouteEventType,
|
|
163
|
+
pathname: BlobPathname,
|
|
164
|
+
options: ClientUploadOptions & UploadRouteOptions,
|
|
165
|
+
): Effect.fn.Return<
|
|
166
|
+
{ readonly status: number; readonly body: unknown },
|
|
167
|
+
BlobUploadAuthorizationFailed
|
|
168
|
+
> {
|
|
169
|
+
const url = yield* resolveUploadRouteUrl(options.handleUploadUrl);
|
|
170
|
+
const event = {
|
|
171
|
+
type,
|
|
172
|
+
payload: {
|
|
173
|
+
pathname,
|
|
174
|
+
clientPayload: options.clientPayload ?? null,
|
|
175
|
+
multipart: options.multipart ?? false,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const request = HttpClientRequest.post(url).pipe(
|
|
179
|
+
HttpClientRequest.bodyText(JSON.stringify(event), "application/json"),
|
|
180
|
+
HttpClientRequest.setHeaders({ "content-type": "application/json", ...options.headers }),
|
|
181
|
+
);
|
|
182
|
+
const response = yield* httpClient.execute(request).pipe(
|
|
183
|
+
Effect.mapError(
|
|
184
|
+
(cause) =>
|
|
185
|
+
new BlobUploadAuthorizationFailed({
|
|
186
|
+
status: Option.none(),
|
|
187
|
+
message: routeFailedMessages[type],
|
|
188
|
+
cause,
|
|
189
|
+
}),
|
|
190
|
+
),
|
|
191
|
+
withoutTracePropagation,
|
|
192
|
+
);
|
|
193
|
+
if (response.status < 200 || response.status >= 300) {
|
|
194
|
+
yield* Effect.ignore(response.arrayBuffer);
|
|
195
|
+
return yield* new BlobUploadAuthorizationFailed({
|
|
196
|
+
status: Option.some(response.status),
|
|
197
|
+
message: routeFailedMessages[type],
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const body = yield* response.json.pipe(
|
|
201
|
+
Effect.mapError(
|
|
202
|
+
(cause) =>
|
|
203
|
+
new BlobUploadAuthorizationFailed({
|
|
204
|
+
status: Option.some(response.status),
|
|
205
|
+
message: routeBodyMessages[type],
|
|
206
|
+
cause,
|
|
207
|
+
}),
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
return { status: response.status, body };
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const retrieveClientToken = Effect.fnUntraced(function* (
|
|
214
|
+
pathname: BlobPathname,
|
|
215
|
+
options: ClientUploadOptions & UploadRouteOptions,
|
|
216
|
+
): Effect.fn.Return<ClientToken, BlobUploadAuthorizationFailed> {
|
|
217
|
+
const type = "blob.generate-client-token";
|
|
218
|
+
const { status, body } = yield* postUploadEvent(type, pathname, options);
|
|
219
|
+
const decoded = yield* Schema.decodeUnknownEffect(ClientTokenResponse)(body).pipe(
|
|
220
|
+
Effect.mapError(
|
|
221
|
+
(cause) =>
|
|
222
|
+
new BlobUploadAuthorizationFailed({
|
|
223
|
+
status: Option.some(status),
|
|
224
|
+
message: routeBodyMessages[type],
|
|
225
|
+
cause,
|
|
226
|
+
}),
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
return decoded.clientToken;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const retrievePresignedAuth = Effect.fnUntraced(function* (
|
|
233
|
+
pathname: BlobPathname,
|
|
234
|
+
options: ClientUploadOptions & UploadRouteOptions,
|
|
235
|
+
): Effect.fn.Return<BlobRequestAuth, BlobUploadAuthorizationFailed> {
|
|
236
|
+
const type = "blob.generate-presigned-url";
|
|
237
|
+
const { status, body } = yield* postUploadEvent(type, pathname, options);
|
|
238
|
+
const fail = (message: string, cause?: unknown) =>
|
|
239
|
+
new BlobUploadAuthorizationFailed({ status: Option.some(status), message, cause });
|
|
240
|
+
const decoded = yield* Schema.decodeUnknownEffect(PresignedUrlResponse)(body).pipe(
|
|
241
|
+
Effect.mapError((cause) => fail(routeBodyMessages[type], cause)),
|
|
242
|
+
);
|
|
243
|
+
const payload = decoded.presignedUrlPayload;
|
|
244
|
+
if (payload === undefined || payload === null) {
|
|
245
|
+
return yield* fail("Missing presignedUrlPayload");
|
|
246
|
+
}
|
|
247
|
+
const storeId = storeIdFromDelegationToken(payload.delegationToken);
|
|
248
|
+
if (Option.isNone(storeId)) {
|
|
249
|
+
return yield* fail(
|
|
250
|
+
payload.delegationToken.includes(".")
|
|
251
|
+
? "Invalid delegation token payload."
|
|
252
|
+
: "Invalid delegation token format.",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return BlobRequestAuth.Presigned({ payload, storeId: storeId.value });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/** `PUT /?pathname=…` with the client write headers, or automatic multipart. */
|
|
259
|
+
const send = <E, R>(
|
|
260
|
+
operation: string,
|
|
261
|
+
auth: BlobRequestAuth,
|
|
262
|
+
pathname: BlobPathname,
|
|
263
|
+
body: BlobBody<E, R>,
|
|
264
|
+
options: ClientUploadOptions,
|
|
265
|
+
): Effect.Effect<PutBlobResult, BlobClientError | E, R> => {
|
|
266
|
+
if (options.multipart === true) {
|
|
267
|
+
return Multipart.multipartPut(multipartTarget(auth), pathname, body, {
|
|
268
|
+
access: options.access,
|
|
269
|
+
contentType: options.contentType,
|
|
270
|
+
onUploadProgress: options.onUploadProgress,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const headers = clientWriteHeaders(options);
|
|
274
|
+
return requester.call({
|
|
275
|
+
operation,
|
|
276
|
+
method: "PUT",
|
|
277
|
+
path: `/?${new URLSearchParams({ pathname }).toString()}`,
|
|
278
|
+
auth: Effect.succeed(auth),
|
|
279
|
+
headers,
|
|
280
|
+
body,
|
|
281
|
+
response: PutBlobResponse,
|
|
282
|
+
domainErrors: clientDomainErrors,
|
|
283
|
+
onUploadProgress: options.onUploadProgress,
|
|
284
|
+
progressInterval: singleUploadProgressInterval,
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const upload = Effect.fn("BlobClient.upload")(function* <E = never, R = never>(
|
|
289
|
+
pathname: BlobPathname,
|
|
290
|
+
body: BlobBody<E, R>,
|
|
291
|
+
options: ClientUploadOptions & UploadRouteOptions,
|
|
292
|
+
): Effect.fn.Return<PutBlobResult, BlobClientError | BlobUploadAuthorizationFailed | E, R> {
|
|
293
|
+
const token = yield* retrieveClientToken(pathname, options);
|
|
294
|
+
return yield* send("BlobClient.upload", clientTokenAuth(token), pathname, body, options);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const uploadPresigned = Effect.fn("BlobClient.uploadPresigned")(function* <E = never, R = never>(
|
|
298
|
+
pathname: BlobPathname,
|
|
299
|
+
body: BlobBody<E, R>,
|
|
300
|
+
options: ClientUploadOptions & UploadRouteOptions,
|
|
301
|
+
): Effect.fn.Return<PutBlobResult, BlobClientError | BlobUploadAuthorizationFailed | E, R> {
|
|
302
|
+
const auth = yield* retrievePresignedAuth(pathname, options);
|
|
303
|
+
return yield* send("BlobClient.uploadPresigned", auth, pathname, body, options);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const put = Effect.fn("BlobClient.put")(function* <E = never, R = never>(
|
|
307
|
+
pathname: BlobPathname,
|
|
308
|
+
body: BlobBody<E, R>,
|
|
309
|
+
options: ClientUploadOptions & { readonly token: ClientToken },
|
|
310
|
+
): Effect.fn.Return<PutBlobResult, BlobClientError | E, R> {
|
|
311
|
+
return yield* send("BlobClient.put", clientTokenAuth(options.token), pathname, body, options);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const createMultipartUpload = Effect.fn("BlobClient.createMultipartUpload")(function* (
|
|
315
|
+
pathname: BlobPathname,
|
|
316
|
+
options: {
|
|
317
|
+
readonly token: ClientToken;
|
|
318
|
+
readonly access: BlobAccess;
|
|
319
|
+
readonly contentType?: string | undefined;
|
|
320
|
+
},
|
|
321
|
+
): Effect.fn.Return<MultipartUpload, BlobClientError> {
|
|
322
|
+
return yield* Multipart.createMultipartUpload(
|
|
323
|
+
multipartTarget(clientTokenAuth(options.token)),
|
|
324
|
+
pathname,
|
|
325
|
+
{ access: options.access, contentType: options.contentType },
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const uploadPart = Effect.fn("BlobClient.uploadPart")(function* <E = never, R = never>(
|
|
330
|
+
upload: MultipartUpload,
|
|
331
|
+
partNumber: PartNumber,
|
|
332
|
+
body: BlobBody<E, R>,
|
|
333
|
+
options: { readonly token: ClientToken },
|
|
334
|
+
): Effect.fn.Return<UploadedPart, BlobClientError | E, R> {
|
|
335
|
+
return yield* Multipart.uploadPart(
|
|
336
|
+
multipartTarget(clientTokenAuth(options.token)),
|
|
337
|
+
upload,
|
|
338
|
+
partNumber,
|
|
339
|
+
body,
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const completeMultipartUpload = Effect.fn("BlobClient.completeMultipartUpload")(function* (
|
|
344
|
+
upload: MultipartUpload,
|
|
345
|
+
parts: Array.NonEmptyReadonlyArray<UploadedPart>,
|
|
346
|
+
options: { readonly token: ClientToken },
|
|
347
|
+
): Effect.fn.Return<PutBlobResult, BlobClientError> {
|
|
348
|
+
return yield* Multipart.completeMultipartUpload(
|
|
349
|
+
multipartTarget(clientTokenAuth(options.token)),
|
|
350
|
+
upload,
|
|
351
|
+
parts,
|
|
352
|
+
);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
return BlobClient.of({
|
|
356
|
+
upload,
|
|
357
|
+
uploadPresigned,
|
|
358
|
+
put,
|
|
359
|
+
createMultipartUpload,
|
|
360
|
+
uploadPart,
|
|
361
|
+
completeMultipartUpload,
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Builds {@link BlobClient} over the given `HttpClient`, with any given values taking precedence
|
|
367
|
+
* over the environment.
|
|
368
|
+
*
|
|
369
|
+
* Reads, through `Config` (trimmed, blank means absent): `VERCEL_BLOB_API_URL` or
|
|
370
|
+
* `NEXT_PUBLIC_VERCEL_BLOB_API_URL`, `VERCEL_BLOB_API_VERSION_OVERRIDE` or its `NEXT_PUBLIC_` twin
|
|
371
|
+
* (default `13`), `VERCEL_BLOB_RETRIES` (default 10), `VERCEL_BLOB_USE_X_CONTENT_LENGTH`, and
|
|
372
|
+
* `VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API` or its `NEXT_PUBLIC_` twin. In browsers these are
|
|
373
|
+
* usually absent or inlined `NEXT_PUBLIC_*` values, so the defaults apply.
|
|
374
|
+
*
|
|
375
|
+
* Uploads through an upload route with a relative `handleUploadUrl` need `globalThis.location`
|
|
376
|
+
* (pages and web workers); elsewhere that is a defect, so pass an absolute URL.
|
|
377
|
+
*
|
|
378
|
+
* Fails with a `ConfigError` when the API URL or `VERCEL_BLOB_RETRIES` is invalid.
|
|
379
|
+
*/
|
|
380
|
+
export const blobClientLayer = (
|
|
381
|
+
options: BlobClientOptions = {},
|
|
382
|
+
): Layer.Layer<BlobClient, Config.ConfigError, HttpClient.HttpClient> =>
|
|
383
|
+
Layer.effect(BlobClient, makeBlobClient(options));
|
|
384
|
+
|
|
385
|
+
/** {@link BlobClient} configured from the environment; needs an `HttpClient`. */
|
|
386
|
+
export const BlobClientLive: Layer.Layer<BlobClient, Config.ConfigError, HttpClient.HttpClient> =
|
|
387
|
+
blobClientLayer();
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Config } from "effect";
|
|
2
|
+
|
|
3
|
+
import { Effect, Layer, Option, Redacted } from "effect";
|
|
4
|
+
|
|
5
|
+
import type { BlobAccess, StoreId } from "../contracts/blob-domain.ts";
|
|
6
|
+
|
|
7
|
+
import { BlobAuth, BlobConfig } from "../contracts/blob-config.ts";
|
|
8
|
+
import { configError, env, requestEnv } from "../internal/env.ts";
|
|
9
|
+
import { parseStoreId, storeIdFromReadWriteToken } from "../internal/store-id.ts";
|
|
10
|
+
import { defaultBlobBaseUrl } from "../internal/urls.ts";
|
|
11
|
+
|
|
12
|
+
/** Values that replace what {@link BlobConfigLive} reads from the environment. */
|
|
13
|
+
export interface BlobConfigOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Read-write token (`vercel_blob_rw_<storeId>_…`). Takes priority over OIDC and
|
|
16
|
+
* `BLOB_READ_WRITE_TOKEN`, and becomes `readWriteToken`.
|
|
17
|
+
*/
|
|
18
|
+
readonly token?: string | Redacted.Redacted<string> | undefined;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Store to address with the Vercel OIDC token, as `store_<id>` or `<id>`; replaces
|
|
22
|
+
* `BLOB_STORE_ID`. Ignored when `token` is given.
|
|
23
|
+
*/
|
|
24
|
+
readonly storeId?: string | undefined;
|
|
25
|
+
|
|
26
|
+
/** Blob API base URL; replaces `VERCEL_BLOB_API_URL`, for an emulator or a test server. */
|
|
27
|
+
readonly apiUrl?: URL | string | undefined;
|
|
28
|
+
|
|
29
|
+
/** Extra headers for every API request, merged over the environment's. */
|
|
30
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
31
|
+
|
|
32
|
+
/** Retries after the first attempt; replaces `VERCEL_BLOB_RETRIES`. */
|
|
33
|
+
readonly retries?: number | undefined;
|
|
34
|
+
|
|
35
|
+
/** Where `get` reads a store's blobs; see `BlobConfig.blobBaseUrl`. */
|
|
36
|
+
readonly blobBaseUrl?: ((storeId: StoreId, access: BlobAccess) => URL) | undefined;
|
|
37
|
+
|
|
38
|
+
/** Ed25519 public key (PEM) for presigned-upload callbacks; replaces `BLOB_WEBHOOK_PUBLIC_KEY`. */
|
|
39
|
+
readonly webhookPublicKey?: string | undefined;
|
|
40
|
+
|
|
41
|
+
/** Origin for upload-completed callbacks; replaces `VERCEL_BLOB_CALLBACK_URL`. */
|
|
42
|
+
readonly callbackBaseUrl?: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const noCredentials = configError(
|
|
46
|
+
"No blob credentials found. Pass a `token` option, set `BLOB_READ_WRITE_TOKEN`, or use Vercel " +
|
|
47
|
+
"OIDC with a `storeId` option or `BLOB_STORE_ID`.",
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const invalidReadWriteToken = (source: string) =>
|
|
51
|
+
configError(
|
|
52
|
+
`Invalid ${source}: expected a read-write token of the form vercel_blob_rw_<storeId>_<secret>.`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const resolveBlobConfig = Effect.fnUntraced(function* (options: BlobConfigOptions) {
|
|
56
|
+
const { apiUrl, apiVersion, retries, sendContentLength, headers } = yield* requestEnv(options);
|
|
57
|
+
|
|
58
|
+
const envReadWriteToken = Option.map(yield* env("BLOB_READ_WRITE_TOKEN"), (token) =>
|
|
59
|
+
Redacted.make(token),
|
|
60
|
+
);
|
|
61
|
+
const readWriteStoreId = (token: Redacted.Redacted<string>, source: string) =>
|
|
62
|
+
Option.match(storeIdFromReadWriteToken(Redacted.value(token)), {
|
|
63
|
+
onNone: () => Effect.fail(invalidReadWriteToken(source)),
|
|
64
|
+
onSome: Effect.succeed,
|
|
65
|
+
});
|
|
66
|
+
if (Option.isSome(envReadWriteToken)) {
|
|
67
|
+
yield* readWriteStoreId(envReadWriteToken.value, "BLOB_READ_WRITE_TOKEN");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const optionStoreId = Option.filter(
|
|
71
|
+
Option.map(Option.fromUndefinedOr(options.storeId), (id) => id.trim()),
|
|
72
|
+
(id) => id !== "",
|
|
73
|
+
);
|
|
74
|
+
const storeIdInput = Option.isSome(optionStoreId) ? optionStoreId : yield* env("BLOB_STORE_ID");
|
|
75
|
+
|
|
76
|
+
let auth: BlobAuth;
|
|
77
|
+
let readWriteToken = envReadWriteToken;
|
|
78
|
+
if (options.token !== undefined) {
|
|
79
|
+
const token = Redacted.isRedacted(options.token) ? options.token : Redacted.make(options.token);
|
|
80
|
+
auth = BlobAuth.ReadWrite({ token, storeId: yield* readWriteStoreId(token, "token option") });
|
|
81
|
+
readWriteToken = Option.some(token);
|
|
82
|
+
} else if (Option.isSome(storeIdInput)) {
|
|
83
|
+
const storeId = yield* Option.match(parseStoreId(storeIdInput.value), {
|
|
84
|
+
onNone: () =>
|
|
85
|
+
Effect.fail(
|
|
86
|
+
configError(
|
|
87
|
+
`Invalid store id "${storeIdInput.value}": expected store_<id> or <id> without whitespace or "_".`,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
onSome: Effect.succeed,
|
|
91
|
+
});
|
|
92
|
+
auth = BlobAuth.Oidc({ storeId });
|
|
93
|
+
} else if (Option.isSome(envReadWriteToken)) {
|
|
94
|
+
auth = BlobAuth.ReadWrite({
|
|
95
|
+
token: envReadWriteToken.value,
|
|
96
|
+
storeId: yield* readWriteStoreId(envReadWriteToken.value, "BLOB_READ_WRITE_TOKEN"),
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
return yield* Effect.fail(noCredentials);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const webhookPublicKey = Option.isSome(Option.fromUndefinedOr(options.webhookPublicKey))
|
|
103
|
+
? Option.fromUndefinedOr(options.webhookPublicKey)
|
|
104
|
+
: yield* env("BLOB_WEBHOOK_PUBLIC_KEY");
|
|
105
|
+
const callbackBaseUrl = Option.isSome(Option.fromUndefinedOr(options.callbackBaseUrl))
|
|
106
|
+
? Option.fromUndefinedOr(options.callbackBaseUrl)
|
|
107
|
+
: yield* env("VERCEL_BLOB_CALLBACK_URL");
|
|
108
|
+
|
|
109
|
+
return BlobConfig.of({
|
|
110
|
+
apiUrl,
|
|
111
|
+
auth,
|
|
112
|
+
readWriteToken,
|
|
113
|
+
apiVersion,
|
|
114
|
+
retries,
|
|
115
|
+
sendContentLength,
|
|
116
|
+
headers,
|
|
117
|
+
blobBaseUrl: options.blobBaseUrl ?? defaultBlobBaseUrl,
|
|
118
|
+
webhookPublicKey,
|
|
119
|
+
callbackBaseUrl,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Builds {@link BlobConfig} from the environment, with any given values taking precedence.
|
|
125
|
+
*
|
|
126
|
+
* Credentials are resolved once, in the SDK's order: the `token` option; else OIDC when a store id
|
|
127
|
+
* is known (`storeId` option or `BLOB_STORE_ID`), keeping `BLOB_READ_WRITE_TOKEN` as the per-request
|
|
128
|
+
* fallback; else `BLOB_READ_WRITE_TOKEN`. Variables are trimmed and blank ones ignored.
|
|
129
|
+
*
|
|
130
|
+
* Fails with a `ConfigError` when no credentials are found, a read-write token has no store id
|
|
131
|
+
* (`vercel_blob_rw_<storeId>_…`), the store id, API URL or `VERCEL_BLOB_RETRIES` is invalid.
|
|
132
|
+
*/
|
|
133
|
+
export const blobConfigLayer = (
|
|
134
|
+
options: BlobConfigOptions = {},
|
|
135
|
+
): Layer.Layer<BlobConfig, Config.ConfigError> =>
|
|
136
|
+
Layer.effect(BlobConfig, resolveBlobConfig(options));
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reads {@link BlobConfig} from the environment: `BLOB_READ_WRITE_TOKEN`, `BLOB_STORE_ID`,
|
|
140
|
+
* `VERCEL_BLOB_API_URL`, `VERCEL_BLOB_API_VERSION_OVERRIDE`, `VERCEL_BLOB_RETRIES` (default 10),
|
|
141
|
+
* `VERCEL_BLOB_USE_X_CONTENT_LENGTH`, `VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API`,
|
|
142
|
+
* `BLOB_WEBHOOK_PUBLIC_KEY` and `VERCEL_BLOB_CALLBACK_URL`. The API URL, API version and proxy
|
|
143
|
+
* variables also accept a `NEXT_PUBLIC_` prefix.
|
|
144
|
+
*/
|
|
145
|
+
export const BlobConfigLive: Layer.Layer<BlobConfig, Config.ConfigError> = blobConfigLayer();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { HttpClient } from "effect/http";
|
|
2
|
+
|
|
3
|
+
import { VercelOidcLive, VercelSystemEnvLive } from "@panorama-industries/effect-vercel-core";
|
|
4
|
+
import { Config, Effect, Layer } from "effect";
|
|
5
|
+
|
|
6
|
+
import type { BlobSignedUrls } from "../contracts/blob-signed-urls.ts";
|
|
7
|
+
import type { BlobStore } from "../contracts/blob-store.ts";
|
|
8
|
+
import type { BlobUploads } from "../contracts/blob-uploads.ts";
|
|
9
|
+
import type { BlobConfigOptions } from "./blob-config-live.ts";
|
|
10
|
+
|
|
11
|
+
import { blobConfigLayer } from "./blob-config-live.ts";
|
|
12
|
+
import { BlobSignedUrlsLive } from "./blob-signed-urls-live.ts";
|
|
13
|
+
import { BlobStoreLive } from "./blob-store-live.ts";
|
|
14
|
+
import { BlobUploadsLive } from "./blob-uploads-live.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Store, signed URLs and upload routes, with the given values taking precedence over the Vercel
|
|
18
|
+
* environment (see `blobConfigLayer`). Provides Vercel OIDC and the system environment itself, so
|
|
19
|
+
* only an `HttpClient` is left to provide, e.g. `FetchHttpClient.layer`.
|
|
20
|
+
*
|
|
21
|
+
* Compose the individual layers instead to refresh OIDC tokens in local development
|
|
22
|
+
* (`VercelOidcRefreshingLive`).
|
|
23
|
+
*/
|
|
24
|
+
export const blobLayer = (
|
|
25
|
+
options: BlobConfigOptions = {},
|
|
26
|
+
): Layer.Layer<
|
|
27
|
+
BlobStore | BlobSignedUrls | BlobUploads,
|
|
28
|
+
Config.ConfigError,
|
|
29
|
+
HttpClient.HttpClient
|
|
30
|
+
> =>
|
|
31
|
+
Layer.mergeAll(BlobStoreLive, BlobSignedUrlsLive, BlobUploadsLive).pipe(
|
|
32
|
+
Layer.provide(Layer.mergeAll(blobConfigLayer(options), VercelOidcLive, VercelSystemEnvLive)),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* {@link blobLayer} with its options read from `Config`, either one `Config` of the options or a
|
|
37
|
+
* record of a `Config` per option, e.g. `blobLayerConfig({ token: Config.Redacted("MY_TOKEN") })`.
|
|
38
|
+
*/
|
|
39
|
+
export const blobLayerConfig = (
|
|
40
|
+
options: Config.Wrap<BlobConfigOptions>,
|
|
41
|
+
): Layer.Layer<
|
|
42
|
+
BlobStore | BlobSignedUrls | BlobUploads,
|
|
43
|
+
Config.ConfigError,
|
|
44
|
+
HttpClient.HttpClient
|
|
45
|
+
> => Layer.unwrap(Effect.map(Config.unwrap(options), blobLayer));
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* {@link blobLayer} configured from the Vercel environment alone (`BLOB_READ_WRITE_TOKEN`, or the
|
|
49
|
+
* function's OIDC token with `BLOB_STORE_ID`).
|
|
50
|
+
*/
|
|
51
|
+
export const BlobLive: Layer.Layer<
|
|
52
|
+
BlobStore | BlobSignedUrls | BlobUploads,
|
|
53
|
+
Config.ConfigError,
|
|
54
|
+
HttpClient.HttpClient
|
|
55
|
+
> = blobLayer();
|