@panorama-industries/effect-vercel-blob 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -1
  3. package/dist/client.d.ts +8 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +8 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/contracts/blob-client.d.ts +67 -0
  8. package/dist/contracts/blob-client.d.ts.map +1 -0
  9. package/dist/contracts/blob-client.js +11 -0
  10. package/dist/contracts/blob-client.js.map +1 -0
  11. package/dist/contracts/blob-config.d.ts +114 -0
  12. package/dist/contracts/blob-config.d.ts.map +1 -0
  13. package/dist/contracts/blob-config.js +7 -0
  14. package/dist/contracts/blob-config.js.map +1 -0
  15. package/dist/contracts/blob-domain.d.ts +336 -0
  16. package/dist/contracts/blob-domain.d.ts.map +1 -0
  17. package/dist/contracts/blob-domain.js +152 -0
  18. package/dist/contracts/blob-domain.js.map +1 -0
  19. package/dist/contracts/blob-errors.d.ts +125 -0
  20. package/dist/contracts/blob-errors.d.ts.map +1 -0
  21. package/dist/contracts/blob-errors.js +114 -0
  22. package/dist/contracts/blob-errors.js.map +1 -0
  23. package/dist/contracts/blob-signed-urls.d.ts +83 -0
  24. package/dist/contracts/blob-signed-urls.d.ts.map +1 -0
  25. package/dist/contracts/blob-signed-urls.js +10 -0
  26. package/dist/contracts/blob-signed-urls.js.map +1 -0
  27. package/dist/contracts/blob-store.d.ts +153 -0
  28. package/dist/contracts/blob-store.d.ts.map +1 -0
  29. package/dist/contracts/blob-store.js +15 -0
  30. package/dist/contracts/blob-store.js.map +1 -0
  31. package/dist/contracts/blob-uploads.d.ts +101 -0
  32. package/dist/contracts/blob-uploads.d.ts.map +1 -0
  33. package/dist/contracts/blob-uploads.js +17 -0
  34. package/dist/contracts/blob-uploads.js.map +1 -0
  35. package/dist/implementation/blob-client-live.d.ts +37 -0
  36. package/dist/implementation/blob-client-live.d.ts.map +1 -0
  37. package/dist/implementation/blob-client-live.js +213 -0
  38. package/dist/implementation/blob-client-live.js.map +1 -0
  39. package/dist/implementation/blob-config-live.d.ts +49 -0
  40. package/dist/implementation/blob-config-live.d.ts.map +1 -0
  41. package/dist/implementation/blob-config-live.js +82 -0
  42. package/dist/implementation/blob-config-live.js.map +1 -0
  43. package/dist/implementation/blob-live.d.ts +16 -0
  44. package/dist/implementation/blob-live.d.ts.map +1 -0
  45. package/dist/implementation/blob-live.js +16 -0
  46. package/dist/implementation/blob-live.js.map +1 -0
  47. package/dist/implementation/blob-signed-urls-live.d.ts +12 -0
  48. package/dist/implementation/blob-signed-urls-live.d.ts.map +1 -0
  49. package/dist/implementation/blob-signed-urls-live.js +92 -0
  50. package/dist/implementation/blob-signed-urls-live.js.map +1 -0
  51. package/dist/implementation/blob-store-live.d.ts +18 -0
  52. package/dist/implementation/blob-store-live.d.ts.map +1 -0
  53. package/dist/implementation/blob-store-live.js +364 -0
  54. package/dist/implementation/blob-store-live.js.map +1 -0
  55. package/dist/implementation/blob-uploads-live.d.ts +11 -0
  56. package/dist/implementation/blob-uploads-live.d.ts.map +1 -0
  57. package/dist/implementation/blob-uploads-live.js +218 -0
  58. package/dist/implementation/blob-uploads-live.js.map +1 -0
  59. package/dist/index.d.ts +15 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +15 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/internal/blob-errors.d.ts +79 -0
  64. package/dist/internal/blob-errors.d.ts.map +1 -0
  65. package/dist/internal/blob-errors.js +196 -0
  66. package/dist/internal/blob-errors.js.map +1 -0
  67. package/dist/internal/blob-request.d.ts +255 -0
  68. package/dist/internal/blob-request.d.ts.map +1 -0
  69. package/dist/internal/blob-request.js +291 -0
  70. package/dist/internal/blob-request.js.map +1 -0
  71. package/dist/internal/callback.d.ts +70 -0
  72. package/dist/internal/callback.d.ts.map +1 -0
  73. package/dist/internal/callback.js +141 -0
  74. package/dist/internal/callback.js.map +1 -0
  75. package/dist/internal/client-token.d.ts +65 -0
  76. package/dist/internal/client-token.d.ts.map +1 -0
  77. package/dist/internal/client-token.js +80 -0
  78. package/dist/internal/client-token.js.map +1 -0
  79. package/dist/internal/env.d.ts +34 -0
  80. package/dist/internal/env.d.ts.map +1 -0
  81. package/dist/internal/env.js +61 -0
  82. package/dist/internal/env.js.map +1 -0
  83. package/dist/internal/multipart.d.ts +110 -0
  84. package/dist/internal/multipart.d.ts.map +1 -0
  85. package/dist/internal/multipart.js +290 -0
  86. package/dist/internal/multipart.js.map +1 -0
  87. package/dist/internal/presign.d.ts +123 -0
  88. package/dist/internal/presign.d.ts.map +1 -0
  89. package/dist/internal/presign.js +345 -0
  90. package/dist/internal/presign.js.map +1 -0
  91. package/dist/internal/store-id.d.ts +38 -0
  92. package/dist/internal/store-id.d.ts.map +1 -0
  93. package/dist/internal/store-id.js +54 -0
  94. package/dist/internal/store-id.js.map +1 -0
  95. package/dist/internal/urls.d.ts +26 -0
  96. package/dist/internal/urls.d.ts.map +1 -0
  97. package/dist/internal/urls.js +33 -0
  98. package/dist/internal/urls.js.map +1 -0
  99. package/dist/internal/wire.d.ts +55 -0
  100. package/dist/internal/wire.d.ts.map +1 -0
  101. package/dist/internal/wire.js +64 -0
  102. package/dist/internal/wire.js.map +1 -0
  103. package/dist/internal/write-headers.d.ts +56 -0
  104. package/dist/internal/write-headers.d.ts.map +1 -0
  105. package/dist/internal/write-headers.js +82 -0
  106. package/dist/internal/write-headers.js.map +1 -0
  107. package/dist/testing/index.d.ts +2 -0
  108. package/dist/testing/index.d.ts.map +1 -0
  109. package/dist/testing/index.js +2 -0
  110. package/dist/testing/index.js.map +1 -0
  111. package/dist/testing/test-blob-store.d.ts +134 -0
  112. package/dist/testing/test-blob-store.d.ts.map +1 -0
  113. package/dist/testing/test-blob-store.js +718 -0
  114. package/dist/testing/test-blob-store.js.map +1 -0
  115. package/package.json +58 -4
  116. package/src/client.ts +11 -0
  117. package/src/contracts/blob-client.ts +121 -0
  118. package/src/contracts/blob-config.ts +64 -0
  119. package/src/contracts/blob-domain.ts +304 -0
  120. package/src/contracts/blob-errors.ts +161 -0
  121. package/src/contracts/blob-signed-urls.ts +112 -0
  122. package/src/contracts/blob-store.ts +258 -0
  123. package/src/contracts/blob-uploads.ts +151 -0
  124. package/src/implementation/blob-client-live.ts +387 -0
  125. package/src/implementation/blob-config-live.ts +145 -0
  126. package/src/implementation/blob-live.ts +30 -0
  127. package/src/implementation/blob-signed-urls-live.ts +121 -0
  128. package/src/implementation/blob-store-live.ts +528 -0
  129. package/src/implementation/blob-uploads-live.ts +326 -0
  130. package/src/index.ts +18 -0
  131. package/src/internal/blob-errors.ts +325 -0
  132. package/src/internal/blob-request.ts +617 -0
  133. package/src/internal/callback.ts +200 -0
  134. package/src/internal/client-token.ts +132 -0
  135. package/src/internal/env.ts +128 -0
  136. package/src/internal/multipart.ts +526 -0
  137. package/src/internal/presign.ts +477 -0
  138. package/src/internal/store-id.ts +69 -0
  139. package/src/internal/urls.ts +42 -0
  140. package/src/internal/wire.ts +80 -0
  141. package/src/internal/write-headers.ts +118 -0
  142. package/src/testing/index.ts +1 -0
  143. package/src/testing/test-blob-store.ts +1136 -0
@@ -0,0 +1,326 @@
1
+ import { VercelSystemEnv } from "@panorama-industries/effect-vercel-core";
2
+ import { Clock, DateTime, Effect, Layer, Option, Redacted, Schema } from "effect";
3
+ import { HttpServerRequest, HttpServerResponse } from "effect/http";
4
+
5
+ import type {
6
+ ClientTokenOptions,
7
+ PresignedUploadHandlerOptions,
8
+ UploadHandlerOptions,
9
+ } from "../contracts/blob-uploads.ts";
10
+ import type { ClientTokenFields } from "../internal/client-token.ts";
11
+
12
+ import { BlobConfig } from "../contracts/blob-config.ts";
13
+ import { UploadCompleted, UploadRequest } from "../contracts/blob-domain.ts";
14
+ import {
15
+ BlobReadWriteTokenRequired,
16
+ BlobUploadEventRejected,
17
+ EventMalformed,
18
+ SignatureInvalid,
19
+ SignatureMissing,
20
+ WebhookPublicKeyMissing,
21
+ } from "../contracts/blob-errors.ts";
22
+ import { BlobUploads } from "../contracts/blob-uploads.ts";
23
+ import {
24
+ callbackSignatureHeader,
25
+ deriveCallbackUrl,
26
+ parseCallbackBody,
27
+ resolveCallbackUrl,
28
+ verifyEd25519CallbackSignature,
29
+ verifyHmacCallbackSignature,
30
+ } from "../internal/callback.ts";
31
+ import {
32
+ clientTokenPayloadJson,
33
+ contradictsIfMatch,
34
+ defaultClientTokenLifetimeMillis,
35
+ ifMatchContradictionMessage,
36
+ signClientToken,
37
+ } from "../internal/client-token.ts";
38
+ import { presignPayload } from "../internal/presign.ts";
39
+ import { storeIdFromReadWriteToken } from "../internal/store-id.ts";
40
+ import { PutBlobResponse } from "../internal/wire.ts";
41
+
42
+ /** How long a client token issued by an upload route stays valid by default, as the SDK. */
43
+ const uploadRouteTokenLifetimeMillis = 60 * 60 * 1000;
44
+
45
+ const generateClientTokenEvent = "blob.generate-client-token";
46
+ const generatePresignedUrlEvent = "blob.generate-presigned-url";
47
+ const uploadCompletedEvent = "blob.upload-completed";
48
+
49
+ /** What the browser sends to start an upload; extra fields (such as a `callbackUrl`) are ignored. */
50
+ const UploadRequestPayload = Schema.Struct({
51
+ pathname: Schema.String,
52
+ clientPayload: Schema.OptionFromOptionalNullOr(Schema.String),
53
+ multipart: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(Effect.succeed(false))),
54
+ }).pipe(Schema.decodeTo(UploadRequest));
55
+
56
+ /** The `payload` of `blob.upload-completed`. */
57
+ const UploadCompletedPayload = Schema.Struct({
58
+ blob: PutBlobResponse,
59
+ tokenPayload: Schema.OptionFromOptionalNullOr(Schema.String),
60
+ }).pipe(Schema.decodeTo(UploadCompleted));
61
+
62
+ const UploadRouteEvent = Schema.Struct({
63
+ type: Schema.Literals([generateClientTokenEvent, uploadCompletedEvent]),
64
+ payload: Schema.Unknown,
65
+ });
66
+
67
+ const PresignedRouteEvent = Schema.Struct({
68
+ type: Schema.Literals([generatePresignedUrlEvent, uploadCompletedEvent]),
69
+ payload: Schema.Unknown,
70
+ });
71
+
72
+ const rejected = (reason: BlobUploadEventRejected["reason"]) =>
73
+ new BlobUploadEventRejected({ reason });
74
+
75
+ const malformed = (message: string) => rejected(new EventMalformed({ message }));
76
+
77
+ const decodeOrReject = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown) =>
78
+ Schema.decodeUnknownEffect(schema)(input).pipe(
79
+ Effect.mapError((error) => malformed(error.message)),
80
+ );
81
+
82
+ /**
83
+ * The request's body as the SDK receives it (`await request.json()`), and as callback signatures
84
+ * cover it. A body that cannot be read or is not JSON is `EventMalformed`.
85
+ */
86
+ const readEvent = <S extends Schema.Decoder<{ readonly type: string; readonly payload: unknown }>>(
87
+ schema: S,
88
+ ) =>
89
+ Effect.gen(function* () {
90
+ const request = yield* HttpServerRequest.HttpServerRequest;
91
+ const text = yield* request.text.pipe(
92
+ Effect.mapError(() => malformed("The request body could not be read.")),
93
+ );
94
+ const body = yield* Option.match(parseCallbackBody(text), {
95
+ onNone: () => Effect.fail(malformed("The request body is not JSON.")),
96
+ onSome: Effect.succeed,
97
+ });
98
+ const event = yield* Schema.decodeUnknownEffect(schema)(body.json).pipe(
99
+ Effect.mapError(() => malformed("Invalid event type")),
100
+ );
101
+ return { request, event, normalizedBody: body.normalized };
102
+ });
103
+
104
+ const signatureOf = (request: HttpServerRequest.HttpServerRequest) =>
105
+ Effect.gen(function* () {
106
+ const signature = request.headers[callbackSignatureHeader] ?? "";
107
+ if (signature === "") {
108
+ return yield* rejected(new SignatureMissing());
109
+ }
110
+ return signature;
111
+ });
112
+
113
+ /**
114
+ * Fields of a client token or route authorization in wire form and in the caller's key order,
115
+ * which the SDK serializes as given: `validUntil` becomes epoch milliseconds, unknown keys are
116
+ * dropped.
117
+ */
118
+ const wireFields = (
119
+ options: object,
120
+ keys: ReadonlySet<string>,
121
+ except?: string,
122
+ ): Record<string, unknown> => {
123
+ const fields: Record<string, unknown> = {};
124
+ for (const [key, value] of Object.entries(options)) {
125
+ if (!keys.has(key) || key === except) continue;
126
+ fields[key] =
127
+ key === "validUntil" && DateTime.isDateTime(value) ? DateTime.toEpochMillis(value) : value;
128
+ }
129
+ return fields;
130
+ };
131
+
132
+ const constraintKeys = [
133
+ "maximumSizeInBytes",
134
+ "allowedContentTypes",
135
+ "validUntil",
136
+ "addRandomSuffix",
137
+ "allowOverwrite",
138
+ "cacheControlMaxAge",
139
+ "ifMatch",
140
+ ];
141
+
142
+ const clientTokenOptionKeys: ReadonlySet<string> = new Set([
143
+ "pathname",
144
+ "onUploadCompleted",
145
+ ...constraintKeys,
146
+ ]);
147
+
148
+ const uploadAuthorizationKeys: ReadonlySet<string> = new Set([
149
+ "tokenPayload",
150
+ "callbackUrl",
151
+ ...constraintKeys,
152
+ ]);
153
+
154
+ /**
155
+ * Implements {@link BlobUploads} with the store's read-write token from `BlobConfig` (client tokens
156
+ * and their callbacks) and `BlobConfig.webhookPublicKey` (presigned callbacks). Callback URLs are
157
+ * derived from `BlobConfig.callbackBaseUrl` and `VercelSystemEnv`; the time comes from `Clock`.
158
+ */
159
+ export const BlobUploadsLive: Layer.Layer<BlobUploads, never, BlobConfig | VercelSystemEnv> =
160
+ Layer.effect(
161
+ BlobUploads,
162
+ Effect.gen(function* () {
163
+ const config = yield* BlobConfig;
164
+ const systemEnv = yield* VercelSystemEnv;
165
+
166
+ const readWriteToken = (operation: string) =>
167
+ Option.match(config.readWriteToken, {
168
+ onNone: () => Effect.fail(new BlobReadWriteTokenRequired({ operation })),
169
+ onSome: (token) => Effect.succeed(Redacted.value(token)),
170
+ });
171
+
172
+ /** The SDK's `generateClientTokenFromReadWriteToken`, over fields already in wire form. */
173
+ const signFields = Effect.fnUntraced(function* (
174
+ token: string,
175
+ fields: ClientTokenFields,
176
+ now: number,
177
+ ) {
178
+ if (contradictsIfMatch(fields)) {
179
+ return yield* Effect.die(new Error(ifMatchContradictionMessage));
180
+ }
181
+ const storeId = storeIdFromReadWriteToken(token);
182
+ if (Option.isNone(storeId)) {
183
+ return yield* Effect.die(new Error("Invalid `BLOB_READ_WRITE_TOKEN`"));
184
+ }
185
+ const payload = clientTokenPayloadJson(fields, now + defaultClientTokenLifetimeMillis);
186
+ return yield* signClientToken(token, storeId.value, payload);
187
+ });
188
+
189
+ const callbackUrlFor = (
190
+ request: HttpServerRequest.HttpServerRequest,
191
+ provided: string | undefined,
192
+ handlesCallback: boolean,
193
+ ) =>
194
+ resolveCallbackUrl({
195
+ provided,
196
+ handlesCallback,
197
+ derive: deriveCallbackUrl({
198
+ requestUrl: request.originalUrl,
199
+ callbackBaseUrl: config.callbackBaseUrl,
200
+ env: systemEnv,
201
+ }),
202
+ });
203
+
204
+ const generateClientToken = Effect.fn("BlobUploads.generateClientToken")(function* (
205
+ options: ClientTokenOptions,
206
+ ) {
207
+ const token = yield* readWriteToken("BlobUploads.generateClientToken");
208
+ const now = yield* Clock.currentTimeMillis;
209
+ return yield* signFields(token, wireFields(options, clientTokenOptionKeys), now);
210
+ });
211
+
212
+ const handler = Effect.fn("BlobUploads.handler")(function* <E, R, E2, R2>(
213
+ options: UploadHandlerOptions<E, R, E2, R2>,
214
+ ) {
215
+ const token = yield* readWriteToken("BlobUploads.handler");
216
+ const { request, event, normalizedBody } = yield* readEvent(UploadRouteEvent);
217
+ switch (event.type) {
218
+ case "blob.generate-client-token": {
219
+ const uploadRequest = yield* decodeOrReject(UploadRequestPayload, event.payload);
220
+ const authorization = yield* options.onBeforeGenerateToken(uploadRequest);
221
+ const tokenPayload =
222
+ authorization.tokenPayload ?? Option.getOrNull(uploadRequest.clientPayload);
223
+ const callbackUrl = yield* callbackUrlFor(
224
+ request,
225
+ authorization.callbackUrl,
226
+ options.onUploadCompleted !== undefined,
227
+ );
228
+ const now = yield* Clock.currentTimeMillis;
229
+ const validUntil =
230
+ authorization.validUntil === undefined
231
+ ? now + uploadRouteTokenLifetimeMillis
232
+ : DateTime.toEpochMillis(authorization.validUntil);
233
+ const clientToken = yield* signFields(
234
+ token,
235
+ {
236
+ ...wireFields(authorization, uploadAuthorizationKeys, "callbackUrl"),
237
+ pathname: uploadRequest.pathname,
238
+ onUploadCompleted: Option.match(callbackUrl, {
239
+ onNone: () => undefined,
240
+ onSome: (url) => ({ callbackUrl: url, tokenPayload }),
241
+ }),
242
+ validUntil,
243
+ },
244
+ now,
245
+ );
246
+ return HttpServerResponse.jsonUnsafe({ type: event.type, clientToken });
247
+ }
248
+ case "blob.upload-completed": {
249
+ const signature = yield* signatureOf(request);
250
+ const verified = yield* verifyHmacCallbackSignature(token, signature, normalizedBody);
251
+ if (!verified) {
252
+ return yield* rejected(new SignatureInvalid());
253
+ }
254
+ if (options.onUploadCompleted !== undefined) {
255
+ const completed = yield* decodeOrReject(UploadCompletedPayload, event.payload);
256
+ yield* options.onUploadCompleted(completed);
257
+ }
258
+ return HttpServerResponse.jsonUnsafe({ type: event.type, response: "ok" });
259
+ }
260
+ }
261
+ });
262
+
263
+ const presignedHandler = Effect.fn("BlobUploads.presignedHandler")(function* <E, R, E2, R2>(
264
+ options: PresignedUploadHandlerOptions<E, R, E2, R2>,
265
+ ) {
266
+ if (Option.isNone(config.webhookPublicKey)) {
267
+ return yield* rejected(new WebhookPublicKeyMissing());
268
+ }
269
+ const webhookPublicKey = config.webhookPublicKey.value;
270
+ const { request, event, normalizedBody } = yield* readEvent(PresignedRouteEvent);
271
+ switch (event.type) {
272
+ case "blob.generate-presigned-url": {
273
+ const uploadRequest = yield* decodeOrReject(UploadRequestPayload, event.payload);
274
+ const { token, urlOptions = {} } = yield* options.getSignedToken(uploadRequest);
275
+ const { callbackUrl: providedCallbackUrl, tokenPayload, ...putOptions } = urlOptions;
276
+ const callbackTokenPayload =
277
+ tokenPayload ?? Option.getOrUndefined(uploadRequest.clientPayload);
278
+ const callbackUrl = yield* callbackUrlFor(
279
+ request,
280
+ providedCallbackUrl,
281
+ options.onUploadCompleted !== undefined,
282
+ );
283
+ const payload = yield* presignPayload(
284
+ token,
285
+ {
286
+ ...putOptions,
287
+ onUploadCompleted: Option.match(callbackUrl, {
288
+ onNone: () => undefined,
289
+ onSome: (url) => ({ callbackUrl: url, tokenPayload: callbackTokenPayload }),
290
+ }),
291
+ operation: "put",
292
+ pathname: uploadRequest.pathname,
293
+ },
294
+ yield* Clock.currentTimeMillis,
295
+ );
296
+ return HttpServerResponse.jsonUnsafe({
297
+ type: event.type,
298
+ presignedUrlPayload: {
299
+ delegationToken: payload.delegationToken,
300
+ signature: payload.signature,
301
+ params: payload.params,
302
+ },
303
+ });
304
+ }
305
+ case "blob.upload-completed": {
306
+ const signature = yield* signatureOf(request);
307
+ const verified = yield* verifyEd25519CallbackSignature(
308
+ webhookPublicKey,
309
+ signature,
310
+ normalizedBody,
311
+ );
312
+ if (!verified) {
313
+ return yield* rejected(new SignatureInvalid());
314
+ }
315
+ if (options.onUploadCompleted !== undefined) {
316
+ const completed = yield* decodeOrReject(UploadCompletedPayload, event.payload);
317
+ yield* options.onUploadCompleted(completed);
318
+ }
319
+ return HttpServerResponse.jsonUnsafe({ type: event.type, response: "ok" });
320
+ }
321
+ }
322
+ });
323
+
324
+ return BlobUploads.of({ generateClientToken, handler, presignedHandler });
325
+ }),
326
+ );
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export * from "./contracts/blob-config.ts";
2
+ export * from "./contracts/blob-domain.ts";
3
+ export * from "./contracts/blob-errors.ts";
4
+ export * from "./contracts/blob-signed-urls.ts";
5
+ export * from "./contracts/blob-store.ts";
6
+ export * from "./contracts/blob-uploads.ts";
7
+ export {
8
+ type BlobConfigOptions,
9
+ BlobConfigLive,
10
+ blobConfigLayer,
11
+ } from "./implementation/blob-config-live.ts";
12
+ export { storeIdFromDelegationToken } from "./internal/store-id.ts";
13
+ export { downloadUrl } from "./internal/urls.ts";
14
+ export { BlobStoreLive } from "./implementation/blob-store-live.ts";
15
+ export { BlobSignedUrlsLive } from "./implementation/blob-signed-urls-live.ts";
16
+ export { BlobUploadsLive } from "./implementation/blob-uploads-live.ts";
17
+ export { ClientTokenPayload, getPayloadFromClientToken } from "./internal/client-token.ts";
18
+ export { BlobLive } from "./implementation/blob-live.ts";
@@ -0,0 +1,325 @@
1
+ import type { Headers, HttpClientResponse } from "effect/http";
2
+
3
+ import { VercelApiError, vercelApiErrorFromStatus } from "@panorama-industries/effect-vercel-core";
4
+ import { Clock, Effect, Option, Predicate, Schema } from "effect";
5
+
6
+ import {
7
+ BlobClientTokenNotAllowed,
8
+ BlobNotFound,
9
+ BlobOidcEnvironmentNotAllowed,
10
+ BlobPreconditionFailed,
11
+ BlobStoreNotFound,
12
+ BlobStoreSuspended,
13
+ BlobStoreUnavailable,
14
+ BlobUploadRejected,
15
+ ClientTokenExpired,
16
+ ContentTypeNotAllowed,
17
+ FileTooLarge,
18
+ PathnameMismatch,
19
+ } from "../contracts/blob-errors.ts";
20
+
21
+ /** Every Blob-specific failure an error response can map to. */
22
+ export type BlobDomainError =
23
+ | BlobNotFound
24
+ | BlobStoreUnavailable
25
+ | BlobPreconditionFailed
26
+ | BlobUploadRejected
27
+ | BlobClientTokenNotAllowed
28
+ | BlobOidcEnvironmentNotAllowed;
29
+
30
+ /** Tag of a {@link BlobDomainError}, used to choose which ones an operation reports. */
31
+ export type BlobDomainErrorTag = BlobDomainError["_tag"];
32
+
33
+ /** The domain errors among `K`, plus `VercelApiError` for every other error response. */
34
+ export type BlobApiError<K extends BlobDomainErrorTag = BlobDomainErrorTag> =
35
+ | VercelApiError
36
+ | Extract<BlobDomainError, { readonly _tag: K }>;
37
+
38
+ /** An error response (non-2xx) whose body has been read. */
39
+ export interface BlobErrorResponse {
40
+ /** Traced operation name stored in `VercelApiError.operation`, such as `BlobStore.put`. */
41
+ readonly operation: string;
42
+
43
+ readonly status: number;
44
+
45
+ /** The raw response body; JSON `{"error":{"code","message"}}` when the API produced it. */
46
+ readonly body: string;
47
+
48
+ /** Response headers; `Retry-After` is read from them for `rate_limited`. */
49
+ readonly headers: Headers.Headers;
50
+
51
+ /** The pathname or URL the call addressed, stored in `BlobNotFound.locator`. */
52
+ readonly locator: string;
53
+ }
54
+
55
+ const ErrorBody = Schema.fromJsonString(
56
+ Schema.Struct({
57
+ error: Schema.optionalKey(
58
+ Schema.Struct({
59
+ code: Schema.optionalKey(Schema.String),
60
+ message: Schema.optionalKey(Schema.String),
61
+ }),
62
+ ),
63
+ }),
64
+ );
65
+
66
+ const decodeErrorBody = Schema.decodeUnknownOption(ErrorBody);
67
+
68
+ /** What the SDK's `getBlobError` reads from an error body, after its message-based rewrites. */
69
+ export interface BlobErrorCode {
70
+ /** The effective code: `unknown_error` when the body had none or was not JSON. */
71
+ readonly code: string;
72
+
73
+ /** The code exactly as the body sent it, if it sent one. */
74
+ readonly bodyCode: Option.Option<string>;
75
+
76
+ readonly message: Option.Option<string>;
77
+ }
78
+
79
+ /**
80
+ * Read the error code and message from an error body like the SDK's `getBlobError`, including the
81
+ * rewrites it applies to codes older API clients cannot receive: a message about a disallowed
82
+ * `contentType`, a `"pathname"` that does not match the token, `Token expired`, a file length
83
+ * limit, or OIDC being disabled for an environment selects the matching code.
84
+ */
85
+ export const blobErrorCode = (body: string): BlobErrorCode => {
86
+ const error = Option.flatMap(decodeErrorBody(body), (decoded) =>
87
+ Option.fromUndefinedOr(decoded.error),
88
+ );
89
+ const bodyCode = Option.flatMap(error, (error) => Option.fromUndefinedOr(error.code));
90
+ const message = Option.flatMap(error, (error) => Option.fromUndefinedOr(error.message));
91
+ let code = Option.getOrElse(bodyCode, () => "unknown_error");
92
+ const text = Option.getOrUndefined(message);
93
+ if (text !== undefined) {
94
+ if (text.includes("contentType") && text.includes("is not allowed")) {
95
+ code = "content_type_not_allowed";
96
+ }
97
+ if (text.includes('"pathname"') && text.includes("does not match the token payload")) {
98
+ code = "client_token_pathname_mismatch";
99
+ }
100
+ if (text === "Token expired") {
101
+ code = "client_token_expired";
102
+ }
103
+ if (text.includes("the file length cannot be greater than")) {
104
+ code = "file_too_large";
105
+ }
106
+ if (
107
+ text.startsWith("OIDC is enabled for this project, but not for the") &&
108
+ text.includes("environment.")
109
+ ) {
110
+ code = "oidc_environment_not_allowed";
111
+ }
112
+ }
113
+ return { code, bodyCode, message };
114
+ };
115
+
116
+ const unknownErrorMessage = "Unknown error, please visit https://vercel.com/help.";
117
+
118
+ /**
119
+ * The message the store sends with `bad_request`, instead of a dedicated code, when a key does not
120
+ * exist: the source of a copy or rename, or the blob an `ifMatch` write or delete targets.
121
+ */
122
+ export const missingKeyMessage = "The specified key does not exist.";
123
+
124
+ const domainError = (
125
+ code: string,
126
+ message: Option.Option<string>,
127
+ locator: string,
128
+ ): Option.Option<BlobDomainError> => {
129
+ const text = Option.getOrUndefined(message);
130
+ switch (code) {
131
+ case "not_found":
132
+ return Option.some(new BlobNotFound({ locator }));
133
+ case "store_not_found":
134
+ return Option.some(new BlobStoreUnavailable({ reason: new BlobStoreNotFound() }));
135
+ case "store_suspended":
136
+ return Option.some(new BlobStoreUnavailable({ reason: new BlobStoreSuspended() }));
137
+ case "precondition_failed":
138
+ return Option.some(new BlobPreconditionFailed());
139
+ case "content_type_not_allowed":
140
+ return Option.some(
141
+ new BlobUploadRejected({
142
+ reason: new ContentTypeNotAllowed({ message: `Content type mismatch, ${text}.` }),
143
+ }),
144
+ );
145
+ case "file_too_large":
146
+ return Option.some(
147
+ new BlobUploadRejected({
148
+ reason: new FileTooLarge({ message: `File is too large, ${text}.` }),
149
+ }),
150
+ );
151
+ case "client_token_pathname_mismatch":
152
+ return Option.some(
153
+ new BlobUploadRejected({
154
+ reason: new PathnameMismatch({
155
+ message:
156
+ `Pathname mismatch, ${text}. Check the pathname used in upload() or put() ` +
157
+ "matches the one from the client token.",
158
+ }),
159
+ }),
160
+ );
161
+ case "client_token_expired":
162
+ return Option.some(new BlobUploadRejected({ reason: new ClientTokenExpired() }));
163
+ case "client_token_not_allowed":
164
+ return Option.some(
165
+ new BlobClientTokenNotAllowed({
166
+ message:
167
+ text ??
168
+ "This operation is not available when using a client token. Use a read–write or OIDC token on the server.",
169
+ }),
170
+ );
171
+ case "oidc_environment_not_allowed":
172
+ return Option.some(
173
+ new BlobOidcEnvironmentNotAllowed({
174
+ message:
175
+ text ?? "OIDC is enabled for this project, but not for this token's environment.",
176
+ }),
177
+ );
178
+ default:
179
+ return Option.none();
180
+ }
181
+ };
182
+
183
+ const rateLimitedMessage = (headers: Headers.Headers): string => {
184
+ const seconds = Number.parseInt(headers["retry-after"] ?? "", 10);
185
+ return `Too many requests please lower the number of concurrent requests${
186
+ seconds ? ` - try again in ${seconds} seconds` : ""
187
+ }.`;
188
+ };
189
+
190
+ /**
191
+ * The status whose `Api*` reason the SDK's error matches, and that reason's message. Codes the SDK
192
+ * maps regardless of status (`forbidden`, `bad_request`, `rate_limited`) force their status; the
193
+ * rest keep the response's.
194
+ */
195
+ const apiReasonFor = (
196
+ code: string,
197
+ response: BlobErrorResponse,
198
+ message: Option.Option<string>,
199
+ ): { readonly status: number; readonly message: string } => {
200
+ const status = response.status;
201
+ switch (code) {
202
+ case "forbidden":
203
+ return {
204
+ status: 403,
205
+ message: "Access denied, please provide a valid token for this resource.",
206
+ };
207
+ case "bad_request":
208
+ return { status: 400, message: Option.getOrElse(message, () => "Bad request") };
209
+ case "rate_limited":
210
+ return {
211
+ status: 429,
212
+ message: rateLimitedMessage(response.headers),
213
+ };
214
+ case "service_unavailable":
215
+ return {
216
+ status,
217
+ message: "The blob service is currently not available. Please try again.",
218
+ };
219
+ default:
220
+ return { status, message: Option.getOrElse(message, () => unknownErrorMessage) };
221
+ }
222
+ };
223
+
224
+ /**
225
+ * Map an error response to the failure the SDK's `getBlobError` would throw, as a typed error.
226
+ *
227
+ * Domain codes become the errors in `contracts/blob-errors.ts`; `forbidden`, `bad_request` and
228
+ * `rate_limited` become `VercelApiError` with `ApiForbidden`, `ApiBadRequest` and `ApiRateLimited`
229
+ * (with `Retry-After`) whatever the status; every other code becomes the `VercelApiError` matching
230
+ * the status (`vercelApiErrorFromStatus`). `VercelApiError.errorCode` is the code the body sent, if
231
+ * any. A domain code not listed in `domainErrors` is reported as a `VercelApiError` for its status
232
+ * instead, with that code, so an operation only surfaces the domain errors its contract declares.
233
+ *
234
+ * Unlike the SDK, a `bad_request` with {@link missingKeyMessage} becomes `BlobNotFound` for an
235
+ * operation that reports it (a missing copy or rename source), and `BlobPreconditionFailed`
236
+ * otherwise (`ifMatch` on a missing blob, which HTTP treats as a failed precondition).
237
+ *
238
+ * @param now - Current time in epoch milliseconds, to resolve an HTTP-date `Retry-After`.
239
+ * @param domainErrors - Domain errors the operation reports; all of them by default.
240
+ */
241
+ export const blobErrorFromResponse = <K extends BlobDomainErrorTag = BlobDomainErrorTag>(
242
+ response: BlobErrorResponse,
243
+ now: number,
244
+ domainErrors?: ReadonlyArray<K>,
245
+ ): BlobApiError<K> => {
246
+ const { code, bodyCode, message } = blobErrorCode(response.body);
247
+ const reports = (tag: BlobDomainErrorTag) =>
248
+ domainErrors === undefined || (domainErrors as ReadonlyArray<string>).includes(tag);
249
+ if (code === "bad_request" && Option.getOrUndefined(message) === missingKeyMessage) {
250
+ if (reports("BlobNotFound")) {
251
+ return new BlobNotFound({ locator: response.locator }) as BlobApiError<K>;
252
+ }
253
+ if (reports("BlobPreconditionFailed")) {
254
+ return new BlobPreconditionFailed() as BlobApiError<K>;
255
+ }
256
+ }
257
+ const domain = domainError(code, message, response.locator).pipe(
258
+ Option.filter((error) => reports(error._tag)),
259
+ );
260
+ if (Option.isSome(domain)) {
261
+ return domain.value as Extract<BlobDomainError, { readonly _tag: K }>;
262
+ }
263
+ const reason = apiReasonFor(code, response, message);
264
+ return vercelApiErrorFromStatus(
265
+ {
266
+ operation: response.operation,
267
+ status: reason.status,
268
+ message: reason.message,
269
+ errorCode: bodyCode,
270
+ headers: response.headers,
271
+ },
272
+ now,
273
+ );
274
+ };
275
+
276
+ /**
277
+ * Read an error response's body and map it with {@link blobErrorFromResponse}. A body that cannot be
278
+ * read counts as not JSON (`unknown_error`).
279
+ */
280
+ export const blobErrorFromHttpResponse = Effect.fnUntraced(function* <
281
+ K extends BlobDomainErrorTag = BlobDomainErrorTag,
282
+ >(
283
+ response: HttpClientResponse.HttpClientResponse,
284
+ options: {
285
+ readonly operation: string;
286
+ readonly locator: string;
287
+ readonly domainErrors?: ReadonlyArray<K> | undefined;
288
+ },
289
+ ): Effect.fn.Return<never, BlobApiError<K>> {
290
+ const body = yield* Effect.orElseSucceed(response.text, () => "");
291
+ const now = yield* Clock.currentTimeMillis;
292
+ return yield* Effect.fail(
293
+ blobErrorFromResponse(
294
+ {
295
+ operation: options.operation,
296
+ status: response.status,
297
+ body,
298
+ headers: response.headers,
299
+ locator: options.locator,
300
+ },
301
+ now,
302
+ options.domainErrors,
303
+ ),
304
+ );
305
+ });
306
+
307
+ const retryableCodes: ReadonlySet<string> = new Set([
308
+ "unknown_error",
309
+ "service_unavailable",
310
+ "internal_server_error",
311
+ ]);
312
+
313
+ /**
314
+ * Whether the SDK retries a failed attempt: network failures, and error responses whose body code
315
+ * is absent (a body that is not JSON counts as `unknown_error`), `unknown_error`,
316
+ * `service_unavailable` or `internal_server_error`. Never `rate_limited`, a domain error, or any
317
+ * other code. Use it as `vercelApiTransientRetry({ isRetryable })`.
318
+ */
319
+ export const isRetryableBlobError = (error: unknown): boolean =>
320
+ error instanceof VercelApiError &&
321
+ (Predicate.isTagged(error.reason, "ApiNetworkFailure") ||
322
+ Option.match(error.errorCode, {
323
+ onNone: () => true,
324
+ onSome: (code) => retryableCodes.has(code),
325
+ }));