@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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -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 +26 -0
  44. package/dist/implementation/blob-live.d.ts.map +1 -0
  45. package/dist/implementation/blob-live.js +26 -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 +55 -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,200 @@
1
+ import type { VercelSystemEnv } from "@panorama-industries/effect-vercel-core";
2
+
3
+ import { Effect, Option, Result } from "effect";
4
+ import { Base64, Hex } from "effect/encoding";
5
+
6
+ /*
7
+ * Upload-completed callbacks, as the SDK's `client.ts` handles them: where the store sends them
8
+ * (`getCallbackUrl`) and how their `x-vercel-signature` is verified.
9
+ */
10
+
11
+ /** Header that carries the signature of an upload-completed callback. */
12
+ export const callbackSignatureHeader = "x-vercel-signature";
13
+
14
+ /** The SDK's warning when an upload route has `onUploadCompleted` but no callback URL. */
15
+ export const callbackUrlUndeterminedWarning =
16
+ "onUploadCompleted provided but no callbackUrl could be determined. Please provide a callbackUrl in onBeforeGenerateToken or set the VERCEL_BLOB_CALLBACK_URL environment variable.";
17
+
18
+ /** The SDK's warning when an upload route returns a callback URL but has no `onUploadCompleted`. */
19
+ export const callbackUrlUnhandledWarning =
20
+ "callbackUrl was provided but onUploadCompleted is not defined. The callback will not be handled.";
21
+
22
+ /**
23
+ * Path and query of a request URL, absolute or relative, as the SDK's `getPathFromRequestUrl`.
24
+ *
25
+ * @returns None when the URL cannot be parsed.
26
+ */
27
+ export const requestPath = (url: string): Option.Option<string> => {
28
+ const base = "https://dummy.com";
29
+ if (!URL.canParse(url, base)) {
30
+ return Option.none();
31
+ }
32
+ const parsed = new URL(url, base);
33
+ return Option.some(parsed.pathname + parsed.search);
34
+ };
35
+
36
+ /**
37
+ * Where the store should send the upload-completed callback of this route when the route gave no
38
+ * URL, as the SDK's `getCallbackUrl`, in order: `callbackBaseUrl` (`VERCEL_BLOB_CALLBACK_URL`) plus
39
+ * the request's path and query; on Vercel, the branch URL, else the deployment URL, of a preview, or
40
+ * the production URL of production. Logs the SDK's warning when the request URL cannot be parsed or
41
+ * the code runs neither with a callback base URL nor on Vercel.
42
+ *
43
+ * @param requestUrl - The request's original URL, absolute or a path.
44
+ */
45
+ export const deriveCallbackUrl = Effect.fnUntraced(function* (args: {
46
+ readonly requestUrl: string;
47
+ readonly callbackBaseUrl: Option.Option<string>;
48
+ readonly env: VercelSystemEnv["Service"];
49
+ }) {
50
+ const path = requestPath(args.requestUrl);
51
+ if (Option.isNone(path)) {
52
+ yield* Effect.logWarning(callbackUrlUndeterminedWarning);
53
+ return Option.none<string>();
54
+ }
55
+ if (Option.isSome(args.callbackBaseUrl)) {
56
+ return Option.some(`${args.callbackBaseUrl.value}${path.value}`);
57
+ }
58
+ const { env } = args;
59
+ if (!env.isVercel) {
60
+ yield* Effect.logWarning(callbackUrlUndeterminedWarning);
61
+ return Option.none<string>();
62
+ }
63
+ const onHost = (host: string) => `https://${host}${path.value}`;
64
+ if (Option.contains(env.target, "preview")) {
65
+ const previewHost = Option.orElse(env.branchUrl, () => env.url);
66
+ if (Option.isSome(previewHost)) {
67
+ return Option.some(onHost(previewHost.value));
68
+ }
69
+ }
70
+ if (Option.contains(env.target, "production")) {
71
+ return Option.map(env.projectProductionUrl, onHost);
72
+ }
73
+ return Option.none<string>();
74
+ });
75
+
76
+ /**
77
+ * The callback URL to sign into an upload, as `handleUpload` and `handleUploadPresigned` choose it:
78
+ * the route's URL, else (with `onUploadCompleted`) the derived one. Without `onUploadCompleted`, a
79
+ * route URL is kept and the SDK's warning logged, since nothing will handle the callback.
80
+ *
81
+ * @param derive - {@link deriveCallbackUrl} for this request; run only when needed.
82
+ */
83
+ export const resolveCallbackUrl = Effect.fnUntraced(function* <E, R>(args: {
84
+ readonly provided: string | undefined;
85
+ readonly handlesCallback: boolean;
86
+ readonly derive: Effect.Effect<Option.Option<string>, E, R>;
87
+ }) {
88
+ let callbackUrl = Option.filter(Option.fromUndefinedOr(args.provided), (url) => url !== "");
89
+ if (args.handlesCallback && Option.isNone(callbackUrl)) {
90
+ callbackUrl = yield* args.derive;
91
+ }
92
+ if (!args.handlesCallback && Option.isSome(callbackUrl)) {
93
+ yield* Effect.logWarning(callbackUrlUnhandledWarning);
94
+ }
95
+ return callbackUrl;
96
+ });
97
+
98
+ /**
99
+ * The text a callback signature covers: the body re-serialized as `JSON.stringify(JSON.parse(body))`,
100
+ * as the SDK signs the parsed body.
101
+ *
102
+ * @returns None when the body is not JSON.
103
+ */
104
+ export const parseCallbackBody = (
105
+ body: string,
106
+ ): Option.Option<{ readonly json: unknown; readonly normalized: string }> => {
107
+ try {
108
+ const json: unknown = JSON.parse(body);
109
+ return Option.some({ json, normalized: JSON.stringify(json) });
110
+ } catch {
111
+ return Option.none();
112
+ }
113
+ };
114
+
115
+ const utf8 = new TextEncoder();
116
+
117
+ /**
118
+ * Whether `signatureHex` is the HMAC-SHA256 (hex) of `body` keyed by the store's read-write token,
119
+ * as `handleUpload` verifies `x-vercel-signature`. Compared in constant time by Web Crypto `verify`;
120
+ * a signature that is not hex never verifies.
121
+ */
122
+ export const verifyHmacCallbackSignature = Effect.fnUntraced(function* (
123
+ readWriteToken: string,
124
+ signatureHex: string,
125
+ body: string,
126
+ ) {
127
+ const signature = Result.getSuccess(Hex.decode(signatureHex));
128
+ if (Option.isNone(signature)) {
129
+ return false;
130
+ }
131
+ const key = yield* Effect.promise(() =>
132
+ globalThis.crypto.subtle.importKey(
133
+ "raw",
134
+ utf8.encode(readWriteToken),
135
+ { name: "HMAC", hash: "SHA-256" },
136
+ false,
137
+ ["verify"],
138
+ ),
139
+ );
140
+ return yield* Effect.promise(() =>
141
+ globalThis.crypto.subtle.verify(
142
+ "HMAC",
143
+ key,
144
+ Uint8Array.from(signature.value),
145
+ utf8.encode(body),
146
+ ),
147
+ );
148
+ });
149
+
150
+ /**
151
+ * DER bytes of a PEM `-----BEGIN PUBLIC KEY-----` (SPKI) block, as the SDK's
152
+ * `publicKeyDerFromPem`.
153
+ *
154
+ * @returns None when there is no such block or its content is not base64.
155
+ */
156
+ export const publicKeyDerFromPem = (pem: string): Option.Option<Uint8Array> => {
157
+ const match = /-----BEGIN PUBLIC KEY-----([^-]*)-----END PUBLIC KEY-----/s.exec(pem.trim());
158
+ const base64 = match?.[1]?.replace(/\s+/g, "");
159
+ if (!base64) {
160
+ return Option.none();
161
+ }
162
+ return Result.getSuccess(Base64.decode(base64));
163
+ };
164
+
165
+ const ed25519SignatureHex = /^[0-9a-fA-F]{128}$/;
166
+
167
+ /**
168
+ * Whether `signatureHex` is an Ed25519 signature of `body` by the key in `publicKeyPem` (SPKI PEM,
169
+ * `BLOB_WEBHOOK_PUBLIC_KEY`), as `handleUploadPresigned` verifies `x-vercel-signature`. The
170
+ * signature must be 128 hex characters; a key that cannot be imported never verifies.
171
+ */
172
+ export const verifyEd25519CallbackSignature = Effect.fnUntraced(function* (
173
+ publicKeyPem: string,
174
+ signatureHex: string,
175
+ body: string,
176
+ ) {
177
+ if (!ed25519SignatureHex.test(signatureHex)) {
178
+ return false;
179
+ }
180
+ const der = publicKeyDerFromPem(publicKeyPem);
181
+ const signature = Result.getSuccess(Hex.decode(signatureHex));
182
+ if (Option.isNone(der) || Option.isNone(signature)) {
183
+ return false;
184
+ }
185
+ return yield* Effect.tryPromise(async () => {
186
+ const key = await globalThis.crypto.subtle.importKey(
187
+ "spki",
188
+ Uint8Array.from(der.value),
189
+ { name: "Ed25519" },
190
+ false,
191
+ ["verify"],
192
+ );
193
+ return globalThis.crypto.subtle.verify(
194
+ "Ed25519",
195
+ key,
196
+ Uint8Array.from(signature.value),
197
+ utf8.encode(body),
198
+ );
199
+ }).pipe(Effect.orElseSucceed(() => false));
200
+ });
@@ -0,0 +1,132 @@
1
+ import { Effect, Option, Result, Schema } from "effect";
2
+ import { Base64, Hex } from "effect/encoding";
3
+
4
+ import type { StoreId } from "../contracts/blob-domain.ts";
5
+
6
+ import { ClientToken } from "../contracts/blob-domain.ts";
7
+
8
+ /*
9
+ * Client tokens, as the SDK's `generateClientTokenFromReadWriteToken` builds them:
10
+ * `vercel_blob_client_<storeId>_<base64("<hex HMAC-SHA256(payload)>.<payload>")>`, where `payload` is
11
+ * the base64 of the token's JSON and the HMAC is keyed by the store's read-write token. The JSON is
12
+ * serialized exactly as the SDK does (key order and all), so tokens are byte-identical.
13
+ */
14
+
15
+ /** Prefix of every client token. */
16
+ export const clientTokenPrefix = "vercel_blob_client_";
17
+
18
+ /** How long a client token generated without `validUntil` stays valid, as the SDK: 30 seconds. */
19
+ export const defaultClientTokenLifetimeMillis = 30_000;
20
+
21
+ /**
22
+ * Fields of a client token's JSON, already in wire form (`validUntil` in epoch milliseconds), in
23
+ * the order they are serialized. `undefined` values are omitted, as `JSON.stringify` omits them.
24
+ */
25
+ export type ClientTokenFields = Readonly<Record<string, unknown>>;
26
+
27
+ /**
28
+ * The JSON a client token encodes, as the SDK serializes it: the fields in their order, with
29
+ * `allowOverwrite: true` appended when `ifMatch` is set and `allowOverwrite` is not, and
30
+ * `validUntil` defaulted to `defaultValidUntil` (kept in place when the fields have it).
31
+ *
32
+ * Callers reject `ifMatch` with `allowOverwrite: false` first; see {@link contradictsIfMatch}.
33
+ */
34
+ export const clientTokenPayloadJson = (
35
+ fields: ClientTokenFields,
36
+ defaultValidUntil: number,
37
+ ): string => {
38
+ const args: Record<string, unknown> = { ...fields };
39
+ if (Boolean(args["ifMatch"]) && args["allowOverwrite"] === undefined) {
40
+ args["allowOverwrite"] = true;
41
+ }
42
+ return JSON.stringify({ ...args, validUntil: args["validUntil"] ?? defaultValidUntil });
43
+ };
44
+
45
+ /** The SDK's message for an `ifMatch` with `allowOverwrite: false`, which it refuses to sign. */
46
+ export const ifMatchContradictionMessage =
47
+ "ifMatch and allowOverwrite: false are contradictory. ifMatch is used for conditional overwrites, which requires allowOverwrite to be true.";
48
+
49
+ /** Whether the fields set `ifMatch` together with `allowOverwrite: false`, which the SDK rejects. */
50
+ export const contradictsIfMatch = (fields: ClientTokenFields): boolean =>
51
+ Boolean(fields["ifMatch"]) && fields["allowOverwrite"] === false;
52
+
53
+ const textEncoder = new TextEncoder();
54
+
55
+ /** HMAC-SHA256 of `data` keyed by `secret` (both UTF-8), as lowercase hex. */
56
+ export const hmacSha256Hex = Effect.fnUntraced(function* (secret: string, data: string) {
57
+ const key = yield* Effect.promise(() =>
58
+ globalThis.crypto.subtle.importKey(
59
+ "raw",
60
+ textEncoder.encode(secret),
61
+ { name: "HMAC", hash: "SHA-256" },
62
+ false,
63
+ ["sign"],
64
+ ),
65
+ );
66
+ const signature = yield* Effect.promise(() =>
67
+ globalThis.crypto.subtle.sign("HMAC", key, textEncoder.encode(data)),
68
+ );
69
+ return Hex.encode(new Uint8Array(signature));
70
+ });
71
+
72
+ /**
73
+ * Sign a client token's JSON with the store's read-write token.
74
+ *
75
+ * @param payloadJson - The token's JSON, from {@link clientTokenPayloadJson}.
76
+ * @returns `vercel_blob_client_<storeId>_<base64("<hex HMAC>.<base64 payloadJson>")>`.
77
+ */
78
+ export const signClientToken = Effect.fnUntraced(function* (
79
+ readWriteToken: string,
80
+ storeId: StoreId,
81
+ payloadJson: string,
82
+ ) {
83
+ const payload = Base64.encode(payloadJson);
84
+ const signature = yield* hmacSha256Hex(readWriteToken, payload);
85
+ return ClientToken.make(
86
+ `${clientTokenPrefix}${storeId}_${Base64.encode(`${signature}.${payload}`)}`,
87
+ );
88
+ });
89
+
90
+ /** `onUploadCompleted` as a client token carries it. */
91
+ export const ClientTokenCallback = Schema.Struct({
92
+ callbackUrl: Schema.String,
93
+ tokenPayload: Schema.optionalKey(Schema.NullOr(Schema.String)),
94
+ });
95
+
96
+ /** The JSON a client token encodes, as the SDK's `DecodedClientTokenPayload`. */
97
+ export const ClientTokenPayload = Schema.Struct({
98
+ pathname: Schema.String,
99
+ /** Expiry in epoch milliseconds. */
100
+ validUntil: Schema.Number,
101
+ onUploadCompleted: Schema.optionalKey(ClientTokenCallback),
102
+ maximumSizeInBytes: Schema.optionalKey(Schema.Number),
103
+ allowedContentTypes: Schema.optionalKey(Schema.Array(Schema.String)),
104
+ addRandomSuffix: Schema.optionalKey(Schema.Boolean),
105
+ allowOverwrite: Schema.optionalKey(Schema.Boolean),
106
+ cacheControlMaxAge: Schema.optionalKey(Schema.Number),
107
+ ifMatch: Schema.optionalKey(Schema.String),
108
+ /** Set by upload routes, which copy the route's `tokenPayload` into the token as the SDK does. */
109
+ tokenPayload: Schema.optionalKey(Schema.NullOr(Schema.String)),
110
+ });
111
+
112
+ /** The JSON a client token encodes. */
113
+ export type ClientTokenPayload = typeof ClientTokenPayload.Type;
114
+
115
+ const decodePayload = Schema.decodeUnknownOption(Schema.fromJsonString(ClientTokenPayload));
116
+
117
+ const decodeBase64String = (input: string): Option.Option<string> =>
118
+ Result.getSuccess(Base64.decodeString(input));
119
+
120
+ /**
121
+ * Read the JSON a client token encodes without verifying its signature, as the SDK's
122
+ * `getPayloadFromClientToken`: the fifth `_`-separated segment is base64 of `<hmac>.<base64 JSON>`.
123
+ * Works in browsers.
124
+ *
125
+ * @returns None when the token does not have that shape or its JSON is not a client token payload.
126
+ */
127
+ export const getPayloadFromClientToken = (clientToken: string): Option.Option<ClientTokenPayload> =>
128
+ decodeBase64String(clientToken.split("_")[4] ?? "").pipe(
129
+ Option.flatMap((secured) => Option.fromUndefinedOr(secured.split(".")[1])),
130
+ Option.flatMap(decodeBase64String),
131
+ Option.flatMap(decodePayload),
132
+ );
@@ -0,0 +1,128 @@
1
+ import { Config, ConfigProvider, Effect, Option, Schema } from "effect";
2
+
3
+ import { defaultApiUrl } from "./urls.ts";
4
+
5
+ const defaultApiVersion = "13";
6
+
7
+ const defaultRetries = 10;
8
+
9
+ /** A `ConfigError` with a message explaining what to fix. */
10
+ export const configError = (message: string) =>
11
+ new Config.ConfigError(new ConfigProvider.SourceError({ message }));
12
+
13
+ /**
14
+ * A variable as the SDK's `readEnv` reads it: trimmed, and absent when blank. In browsers these
15
+ * are usually `NEXT_PUBLIC_*` values inlined by the bundler, or absent.
16
+ */
17
+ export const env = (name: string) =>
18
+ Config.option(Config.String(name)).pipe(
19
+ Config.map((value) =>
20
+ Option.filter(
21
+ Option.map(value, (value) => value.trim()),
22
+ (value) => value !== "",
23
+ ),
24
+ ),
25
+ );
26
+
27
+ /** The first of several variables that is set, such as a name and its `NEXT_PUBLIC_` twin. */
28
+ export const firstEnv = (...names: ReadonlyArray<string>) =>
29
+ Effect.gen(function* () {
30
+ for (const name of names) {
31
+ const value = yield* env(name);
32
+ if (Option.isSome(value)) return value;
33
+ }
34
+ return Option.none<string>();
35
+ });
36
+
37
+ const decodeUrl = Schema.decodeUnknownOption(Schema.URLFromString);
38
+
39
+ const decodeRetries = Schema.decodeUnknownOption(
40
+ Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)),
41
+ );
42
+
43
+ /** Values that replace what {@link requestEnv} reads from the environment. */
44
+ export interface RequestEnvOptions {
45
+ readonly apiUrl?: URL | string | undefined;
46
+ readonly headers?: Readonly<Record<string, string>> | undefined;
47
+ readonly retries?: number | undefined;
48
+ }
49
+
50
+ /** How every Blob API request is addressed and retried, shared by the server and browser clients. */
51
+ export interface RequestEnv {
52
+ readonly apiUrl: URL;
53
+ readonly apiVersion: string;
54
+ readonly retries: number;
55
+ readonly sendContentLength: boolean;
56
+ readonly headers: Readonly<Record<string, string>>;
57
+ }
58
+
59
+ /**
60
+ * Reads the request settings the SDK's `requestApi` reads on every call: `VERCEL_BLOB_API_URL`,
61
+ * `VERCEL_BLOB_API_VERSION_OVERRIDE`, `VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API` (each also with a
62
+ * `NEXT_PUBLIC_` prefix), `VERCEL_BLOB_RETRIES` and `VERCEL_BLOB_USE_X_CONTENT_LENGTH`.
63
+ *
64
+ * Fails with a `ConfigError` when the API URL is not absolute or the retry count is not a
65
+ * non-negative integer.
66
+ */
67
+ export const requestEnv = Effect.fnUntraced(function* (
68
+ options: RequestEnvOptions,
69
+ ): Effect.fn.Return<RequestEnv, Config.ConfigError> {
70
+ const apiUrlInput =
71
+ options.apiUrl ??
72
+ Option.getOrElse(
73
+ yield* firstEnv("VERCEL_BLOB_API_URL", "NEXT_PUBLIC_VERCEL_BLOB_API_URL"),
74
+ () => defaultApiUrl,
75
+ );
76
+ const apiUrl = yield* Option.match(decodeUrl(String(apiUrlInput)), {
77
+ onNone: () =>
78
+ Effect.fail(
79
+ configError(`Invalid Blob API URL "${String(apiUrlInput)}": expected an absolute URL.`),
80
+ ),
81
+ onSome: Effect.succeed,
82
+ });
83
+
84
+ const apiVersion = Option.getOrElse(
85
+ yield* firstEnv(
86
+ "VERCEL_BLOB_API_VERSION_OVERRIDE",
87
+ "NEXT_PUBLIC_VERCEL_BLOB_API_VERSION_OVERRIDE",
88
+ ),
89
+ () => defaultApiVersion,
90
+ );
91
+
92
+ const retries =
93
+ options.retries ??
94
+ (yield* Option.match(yield* env("VERCEL_BLOB_RETRIES"), {
95
+ onNone: () => Effect.succeed(defaultRetries),
96
+ onSome: (value) =>
97
+ Option.match(decodeRetries(value), {
98
+ onNone: () =>
99
+ Effect.fail(
100
+ configError(
101
+ `Invalid VERCEL_BLOB_RETRIES "${value}": expected a non-negative integer.`,
102
+ ),
103
+ ),
104
+ onSome: Effect.succeed,
105
+ }),
106
+ }));
107
+ if (!Number.isInteger(retries) || retries < 0) {
108
+ return yield* Effect.fail(
109
+ configError(`Invalid retries ${retries}: expected a non-negative integer.`),
110
+ );
111
+ }
112
+
113
+ const sendContentLength = Option.contains(yield* env("VERCEL_BLOB_USE_X_CONTENT_LENGTH"), "1");
114
+
115
+ const proxyThroughAlternativeApi = yield* firstEnv(
116
+ "VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API",
117
+ "NEXT_PUBLIC_VERCEL_BLOB_PROXY_THROUGH_ALTERNATIVE_API",
118
+ );
119
+ const headers = {
120
+ ...Option.match(proxyThroughAlternativeApi, {
121
+ onNone: () => ({}),
122
+ onSome: (value) => ({ "x-proxy-through-alternative-api": value }),
123
+ }),
124
+ ...options.headers,
125
+ };
126
+
127
+ return { apiUrl, apiVersion, retries, sendContentLength, headers };
128
+ });