@panorama-industries/effect-vercel-blob 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +13 -2
- package/dist/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +8 -0
- package/dist/client.js.map +1 -0
- package/dist/contracts/blob-client.d.ts +67 -0
- package/dist/contracts/blob-client.d.ts.map +1 -0
- package/dist/contracts/blob-client.js +11 -0
- package/dist/contracts/blob-client.js.map +1 -0
- package/dist/contracts/blob-config.d.ts +114 -0
- package/dist/contracts/blob-config.d.ts.map +1 -0
- package/dist/contracts/blob-config.js +7 -0
- package/dist/contracts/blob-config.js.map +1 -0
- package/dist/contracts/blob-domain.d.ts +336 -0
- package/dist/contracts/blob-domain.d.ts.map +1 -0
- package/dist/contracts/blob-domain.js +152 -0
- package/dist/contracts/blob-domain.js.map +1 -0
- package/dist/contracts/blob-errors.d.ts +125 -0
- package/dist/contracts/blob-errors.d.ts.map +1 -0
- package/dist/contracts/blob-errors.js +114 -0
- package/dist/contracts/blob-errors.js.map +1 -0
- package/dist/contracts/blob-signed-urls.d.ts +83 -0
- package/dist/contracts/blob-signed-urls.d.ts.map +1 -0
- package/dist/contracts/blob-signed-urls.js +10 -0
- package/dist/contracts/blob-signed-urls.js.map +1 -0
- package/dist/contracts/blob-store.d.ts +153 -0
- package/dist/contracts/blob-store.d.ts.map +1 -0
- package/dist/contracts/blob-store.js +15 -0
- package/dist/contracts/blob-store.js.map +1 -0
- package/dist/contracts/blob-uploads.d.ts +101 -0
- package/dist/contracts/blob-uploads.d.ts.map +1 -0
- package/dist/contracts/blob-uploads.js +17 -0
- package/dist/contracts/blob-uploads.js.map +1 -0
- package/dist/implementation/blob-client-live.d.ts +37 -0
- package/dist/implementation/blob-client-live.d.ts.map +1 -0
- package/dist/implementation/blob-client-live.js +213 -0
- package/dist/implementation/blob-client-live.js.map +1 -0
- package/dist/implementation/blob-config-live.d.ts +49 -0
- package/dist/implementation/blob-config-live.d.ts.map +1 -0
- package/dist/implementation/blob-config-live.js +82 -0
- package/dist/implementation/blob-config-live.js.map +1 -0
- package/dist/implementation/blob-live.d.ts +16 -0
- package/dist/implementation/blob-live.d.ts.map +1 -0
- package/dist/implementation/blob-live.js +16 -0
- package/dist/implementation/blob-live.js.map +1 -0
- package/dist/implementation/blob-signed-urls-live.d.ts +12 -0
- package/dist/implementation/blob-signed-urls-live.d.ts.map +1 -0
- package/dist/implementation/blob-signed-urls-live.js +92 -0
- package/dist/implementation/blob-signed-urls-live.js.map +1 -0
- package/dist/implementation/blob-store-live.d.ts +18 -0
- package/dist/implementation/blob-store-live.d.ts.map +1 -0
- package/dist/implementation/blob-store-live.js +364 -0
- package/dist/implementation/blob-store-live.js.map +1 -0
- package/dist/implementation/blob-uploads-live.d.ts +11 -0
- package/dist/implementation/blob-uploads-live.d.ts.map +1 -0
- package/dist/implementation/blob-uploads-live.js +218 -0
- package/dist/implementation/blob-uploads-live.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/blob-errors.d.ts +79 -0
- package/dist/internal/blob-errors.d.ts.map +1 -0
- package/dist/internal/blob-errors.js +196 -0
- package/dist/internal/blob-errors.js.map +1 -0
- package/dist/internal/blob-request.d.ts +255 -0
- package/dist/internal/blob-request.d.ts.map +1 -0
- package/dist/internal/blob-request.js +291 -0
- package/dist/internal/blob-request.js.map +1 -0
- package/dist/internal/callback.d.ts +70 -0
- package/dist/internal/callback.d.ts.map +1 -0
- package/dist/internal/callback.js +141 -0
- package/dist/internal/callback.js.map +1 -0
- package/dist/internal/client-token.d.ts +65 -0
- package/dist/internal/client-token.d.ts.map +1 -0
- package/dist/internal/client-token.js +80 -0
- package/dist/internal/client-token.js.map +1 -0
- package/dist/internal/env.d.ts +34 -0
- package/dist/internal/env.d.ts.map +1 -0
- package/dist/internal/env.js +61 -0
- package/dist/internal/env.js.map +1 -0
- package/dist/internal/multipart.d.ts +110 -0
- package/dist/internal/multipart.d.ts.map +1 -0
- package/dist/internal/multipart.js +290 -0
- package/dist/internal/multipart.js.map +1 -0
- package/dist/internal/presign.d.ts +123 -0
- package/dist/internal/presign.d.ts.map +1 -0
- package/dist/internal/presign.js +345 -0
- package/dist/internal/presign.js.map +1 -0
- package/dist/internal/store-id.d.ts +38 -0
- package/dist/internal/store-id.d.ts.map +1 -0
- package/dist/internal/store-id.js +54 -0
- package/dist/internal/store-id.js.map +1 -0
- package/dist/internal/urls.d.ts +26 -0
- package/dist/internal/urls.d.ts.map +1 -0
- package/dist/internal/urls.js +33 -0
- package/dist/internal/urls.js.map +1 -0
- package/dist/internal/wire.d.ts +55 -0
- package/dist/internal/wire.d.ts.map +1 -0
- package/dist/internal/wire.js +64 -0
- package/dist/internal/wire.js.map +1 -0
- package/dist/internal/write-headers.d.ts +56 -0
- package/dist/internal/write-headers.d.ts.map +1 -0
- package/dist/internal/write-headers.js +82 -0
- package/dist/internal/write-headers.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/test-blob-store.d.ts +134 -0
- package/dist/testing/test-blob-store.d.ts.map +1 -0
- package/dist/testing/test-blob-store.js +718 -0
- package/dist/testing/test-blob-store.js.map +1 -0
- package/package.json +65 -3
- package/src/client.ts +11 -0
- package/src/contracts/blob-client.ts +121 -0
- package/src/contracts/blob-config.ts +64 -0
- package/src/contracts/blob-domain.ts +304 -0
- package/src/contracts/blob-errors.ts +161 -0
- package/src/contracts/blob-signed-urls.ts +112 -0
- package/src/contracts/blob-store.ts +258 -0
- package/src/contracts/blob-uploads.ts +151 -0
- package/src/implementation/blob-client-live.ts +387 -0
- package/src/implementation/blob-config-live.ts +145 -0
- package/src/implementation/blob-live.ts +30 -0
- package/src/implementation/blob-signed-urls-live.ts +121 -0
- package/src/implementation/blob-store-live.ts +528 -0
- package/src/implementation/blob-uploads-live.ts +326 -0
- package/src/index.ts +18 -0
- package/src/internal/blob-errors.ts +325 -0
- package/src/internal/blob-request.ts +617 -0
- package/src/internal/callback.ts +200 -0
- package/src/internal/client-token.ts +132 -0
- package/src/internal/env.ts +128 -0
- package/src/internal/multipart.ts +526 -0
- package/src/internal/presign.ts +477 -0
- package/src/internal/store-id.ts +69 -0
- package/src/internal/urls.ts +42 -0
- package/src/internal/wire.ts +80 -0
- package/src/internal/write-headers.ts +118 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/test-blob-store.ts +1136 -0
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import type { OidcTokenUnavailable, VercelOidc } from "@panorama-industries/effect-vercel-core";
|
|
2
|
+
import type { HttpClientError, HttpClientResponse } from "effect/http";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ApiUnexpectedResponse,
|
|
6
|
+
VercelApiError,
|
|
7
|
+
vercelApiErrorFromHttpClientError,
|
|
8
|
+
vercelApiTransientRetry,
|
|
9
|
+
} from "@panorama-industries/effect-vercel-core";
|
|
10
|
+
import {
|
|
11
|
+
Clock,
|
|
12
|
+
Data,
|
|
13
|
+
Duration,
|
|
14
|
+
Effect,
|
|
15
|
+
Fiber,
|
|
16
|
+
Option,
|
|
17
|
+
Queue,
|
|
18
|
+
Random,
|
|
19
|
+
Redacted,
|
|
20
|
+
Ref,
|
|
21
|
+
Schema,
|
|
22
|
+
Stream,
|
|
23
|
+
} from "effect";
|
|
24
|
+
import { HttpBody, HttpClient, HttpClientRequest } from "effect/http";
|
|
25
|
+
|
|
26
|
+
import type { BlobConfig } from "../contracts/blob-config.ts";
|
|
27
|
+
import type {
|
|
28
|
+
BlobBody,
|
|
29
|
+
OnUploadProgress,
|
|
30
|
+
PresignedUrlPayload,
|
|
31
|
+
UploadProgress,
|
|
32
|
+
} from "../contracts/blob-domain.ts";
|
|
33
|
+
import type { BlobApiError, BlobDomainErrorTag } from "./blob-errors.ts";
|
|
34
|
+
|
|
35
|
+
import { BlobAuth } from "../contracts/blob-config.ts";
|
|
36
|
+
import { blobErrorFromHttpResponse, isRetryableBlobError } from "./blob-errors.ts";
|
|
37
|
+
import { storeIdFromReadWriteToken } from "./store-id.ts";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What every request of one component shares; build it once per layer, from `BlobConfig` with
|
|
41
|
+
* {@link blobRequestSettings} on the server, or from the client options in the browser.
|
|
42
|
+
*/
|
|
43
|
+
export interface BlobRequestSettings {
|
|
44
|
+
readonly httpClient: HttpClient.HttpClient;
|
|
45
|
+
|
|
46
|
+
/** Blob API base URL; a call's `path` is appended to it after removing trailing slashes. */
|
|
47
|
+
readonly apiUrl: URL;
|
|
48
|
+
|
|
49
|
+
/** Sent as `x-api-version`. */
|
|
50
|
+
readonly apiVersion: string;
|
|
51
|
+
|
|
52
|
+
/** Retries after the first attempt; a call whose body is a `Stream` makes one attempt only. */
|
|
53
|
+
readonly retries: number;
|
|
54
|
+
|
|
55
|
+
/** Send `x-content-length` on every call, not only on calls with upload progress. */
|
|
56
|
+
readonly sendContentLength: boolean;
|
|
57
|
+
|
|
58
|
+
/** Sent with every call, after the protocol headers and before the call's own headers. */
|
|
59
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether `fetch` can stream request bodies; detected with {@link supportsRequestStreams} when
|
|
63
|
+
* omitted. When false, bodies are sent whole (streams are read into memory first) and upload
|
|
64
|
+
* progress reports only 0 % and 100 %.
|
|
65
|
+
*/
|
|
66
|
+
readonly streamRequestBodies?: boolean | undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Settings for server-side services, read from `BlobConfig`. */
|
|
70
|
+
export const blobRequestSettings = (
|
|
71
|
+
config: BlobConfig["Service"],
|
|
72
|
+
httpClient: HttpClient.HttpClient,
|
|
73
|
+
): BlobRequestSettings => ({
|
|
74
|
+
httpClient,
|
|
75
|
+
apiUrl: config.apiUrl,
|
|
76
|
+
apiVersion: config.apiVersion,
|
|
77
|
+
retries: config.retries,
|
|
78
|
+
sendContentLength: config.sendContentLength,
|
|
79
|
+
headers: config.headers,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** How one call authenticates, resolved once per call (before the first attempt). */
|
|
83
|
+
export type BlobRequestAuth = Data.TaggedEnum<{
|
|
84
|
+
/**
|
|
85
|
+
* `authorization: Bearer <token>`, for read-write, OIDC and client tokens. `storeId` goes into
|
|
86
|
+
* `x-vercel-blob-store-id` and the request id.
|
|
87
|
+
*/
|
|
88
|
+
Bearer: { readonly token: Redacted.Redacted<string>; readonly storeId: string };
|
|
89
|
+
/**
|
|
90
|
+
* No `authorization`; the payload's `params`, `vercel-blob-delegation` and
|
|
91
|
+
* `vercel-blob-signature` are set on the query. Take `storeId` from the delegation token with
|
|
92
|
+
* `storeIdFromDelegationToken`.
|
|
93
|
+
*/
|
|
94
|
+
Presigned: { readonly payload: PresignedUrlPayload; readonly storeId: string };
|
|
95
|
+
}>;
|
|
96
|
+
|
|
97
|
+
/** Constructors and matchers for {@link BlobRequestAuth}. */
|
|
98
|
+
export const BlobRequestAuth = Data.taggedEnum<BlobRequestAuth>();
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The per-call auth of server-side services. `ReadWrite` uses its token. `Oidc` evaluates
|
|
102
|
+
* `VercelOidc.token` on every call; when it is unavailable and `BlobConfig.readWriteToken` is set,
|
|
103
|
+
* the call uses that token and its store id instead, as the SDK falls through to
|
|
104
|
+
* `BLOB_READ_WRITE_TOKEN`. Otherwise it fails with the `OidcTokenUnavailable`.
|
|
105
|
+
*/
|
|
106
|
+
export const serverBlobAuth = (
|
|
107
|
+
config: BlobConfig["Service"],
|
|
108
|
+
oidc: VercelOidc["Service"],
|
|
109
|
+
): Effect.Effect<BlobRequestAuth, OidcTokenUnavailable> => {
|
|
110
|
+
const fallback = Option.flatMap(config.readWriteToken, (token) =>
|
|
111
|
+
Option.map(storeIdFromReadWriteToken(Redacted.value(token)), (storeId) =>
|
|
112
|
+
BlobRequestAuth.Bearer({ token, storeId }),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
return BlobAuth.$match(config.auth, {
|
|
116
|
+
ReadWrite: ({ token, storeId }) => Effect.succeed(BlobRequestAuth.Bearer({ token, storeId })),
|
|
117
|
+
Oidc: ({ storeId }) =>
|
|
118
|
+
oidc.token.pipe(
|
|
119
|
+
Effect.map((token) => BlobRequestAuth.Bearer({ token, storeId })),
|
|
120
|
+
Effect.catchTag("OidcTokenUnavailable", (error) =>
|
|
121
|
+
Option.match(fallback, {
|
|
122
|
+
onNone: () => Effect.fail(error),
|
|
123
|
+
onSome: Effect.succeed,
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** HTTP methods the Blob API uses. */
|
|
131
|
+
export type BlobRequestMethod = "GET" | "PUT" | "POST" | "DELETE";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One Blob API call for {@link BlobRequester} `call`.
|
|
135
|
+
*
|
|
136
|
+
* @template A - Decoded response body; `void` when `response` is omitted.
|
|
137
|
+
* @template E - Failure of a `Stream` body, reported as is (not as a network failure).
|
|
138
|
+
* @template R - Requirements of a `Stream` body.
|
|
139
|
+
* @template EA - Failure of `auth`, such as `OidcTokenUnavailable`.
|
|
140
|
+
* @template K - Domain errors the operation reports; other domain codes become `VercelApiError`.
|
|
141
|
+
*/
|
|
142
|
+
export interface BlobApiCall<A, E, R, EA, RA, K extends BlobDomainErrorTag> {
|
|
143
|
+
/** Traced name, such as `BlobStore.put`; stored in `VercelApiError.operation`. */
|
|
144
|
+
readonly operation: string;
|
|
145
|
+
|
|
146
|
+
readonly method: BlobRequestMethod;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Path and query appended to the API URL exactly as the SDK appends it: `/?pathname=…` for put,
|
|
150
|
+
* `?url=…` (no slash) for head, `?limit…` for list, `/delete`, `/mpu?pathname=…`. Build queries
|
|
151
|
+
* with `URLSearchParams` so they are encoded like the SDK's.
|
|
152
|
+
*/
|
|
153
|
+
readonly path: string;
|
|
154
|
+
|
|
155
|
+
readonly auth: Effect.Effect<BlobRequestAuth, EA, RA>;
|
|
156
|
+
|
|
157
|
+
/** Decodes the JSON response body (see `internal/wire.ts`); omit to ignore the body. */
|
|
158
|
+
readonly response?: Schema.Decoder<A> | undefined;
|
|
159
|
+
|
|
160
|
+
/** Sent last, so they override every other header (including `authorization`). */
|
|
161
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
162
|
+
|
|
163
|
+
/** Raw request body. Strings, bytes and `Blob`s are resent on retries; streams are not. */
|
|
164
|
+
readonly body?: BlobBody<E, R> | undefined;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A JSON request body, sent as `JSON.stringify(json)` with `content-type: application/json`
|
|
168
|
+
* (before `headers`). Takes precedence over `body`.
|
|
169
|
+
*/
|
|
170
|
+
readonly json?: unknown;
|
|
171
|
+
|
|
172
|
+
/** What `BlobNotFound.locator` reports for `not_found`; defaults to `path`. */
|
|
173
|
+
readonly locator?: string | undefined;
|
|
174
|
+
|
|
175
|
+
/** Domain errors the operation surfaces; all by default. See `blobErrorFromResponse`. */
|
|
176
|
+
readonly domainErrors?: ReadonlyArray<K> | undefined;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Upload progress, reported as the SDK does: `{ loaded: 0, total, percentage: 0 }` before the
|
|
180
|
+
* first attempt, then once per 64 KiB chunk sent (never 100 % while waiting for the response),
|
|
181
|
+
* then `{ loaded, total: loaded, percentage: 100 }` once a successful response arrived. Also sends
|
|
182
|
+
* `x-content-length`. Where `fetch` cannot stream request bodies, only the first and last events
|
|
183
|
+
* are reported.
|
|
184
|
+
*/
|
|
185
|
+
readonly onUploadProgress?: OnUploadProgress | undefined;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Minimum time between chunk events (leading and trailing edge, like `throttleit`); the SDK uses
|
|
189
|
+
* 100 ms for `put`. The first and last events are never delayed. Unthrottled when omitted.
|
|
190
|
+
*/
|
|
191
|
+
readonly progressInterval?: Duration.Input | undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A GET on a blob's own URL, for `get`: no Blob API headers, no retries. */
|
|
195
|
+
export interface BlobFetchRequest {
|
|
196
|
+
/** Traced name, such as `BlobStore.get`; stored in `VercelApiError.operation`. */
|
|
197
|
+
readonly operation: string;
|
|
198
|
+
|
|
199
|
+
readonly url: URL;
|
|
200
|
+
|
|
201
|
+
/** Sent as `authorization: Bearer <token>`. */
|
|
202
|
+
readonly token: Redacted.Redacted<string>;
|
|
203
|
+
|
|
204
|
+
/** Sent after `authorization`, so they override it; put `if-none-match` here. */
|
|
205
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Executes Blob API calls with the SDK's headers, retries, error mapping and progress. */
|
|
209
|
+
export interface BlobRequester {
|
|
210
|
+
/**
|
|
211
|
+
* Send one API call, retrying it like the SDK's `requestApi`, and decode the response.
|
|
212
|
+
*
|
|
213
|
+
* Every attempt carries `x-api-blob-request-id` (`<storeId>:<epoch ms>:<random hex>`, the same
|
|
214
|
+
* for all attempts), `x-api-blob-request-attempt` (`0`, `1`, …), `x-api-version`,
|
|
215
|
+
* `x-vercel-blob-store-id`, `authorization` for bearer auth, `x-content-length` with progress or
|
|
216
|
+
* `sendContentLength`, then the settings' headers, then the call's headers. Network failures and
|
|
217
|
+
* retryable error codes (`isRetryableBlobError`) are retried up to `retries` times with
|
|
218
|
+
* exponential backoff from 1 second; a `Stream` body is sent once.
|
|
219
|
+
*
|
|
220
|
+
* @returns The decoded body; fails with `VercelApiError` (`ApiNetworkFailure` for transport
|
|
221
|
+
* failures, `ApiUnexpectedResponse` for a body `response` cannot decode), a domain error among
|
|
222
|
+
* `domainErrors`, the auth effect's error, or the body stream's error.
|
|
223
|
+
*/
|
|
224
|
+
readonly call: <
|
|
225
|
+
A = void,
|
|
226
|
+
E = never,
|
|
227
|
+
R = never,
|
|
228
|
+
EA = never,
|
|
229
|
+
RA = never,
|
|
230
|
+
K extends BlobDomainErrorTag = BlobDomainErrorTag,
|
|
231
|
+
>(
|
|
232
|
+
call: BlobApiCall<A, E, R, EA, RA, K>,
|
|
233
|
+
) => Effect.Effect<A, BlobApiError<K> | E | EA, R | RA>;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Send a GET to a blob URL and return the response whatever its status; the caller maps 304,
|
|
237
|
+
* 404 and other statuses. Transport failures are `VercelApiError` / `ApiNetworkFailure`.
|
|
238
|
+
*/
|
|
239
|
+
readonly fetch: (
|
|
240
|
+
request: BlobFetchRequest,
|
|
241
|
+
) => Effect.Effect<HttpClientResponse.HttpClientResponse, VercelApiError>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Size of the chunks a progress-tracked body is sent in, as the SDK's `CHUNK_SIZE`. */
|
|
245
|
+
export const progressChunkSize = 64 * 1024;
|
|
246
|
+
|
|
247
|
+
const concatBytes = (a: Uint8Array, b: Uint8Array): Uint8Array => {
|
|
248
|
+
const result = new Uint8Array(a.length + b.length);
|
|
249
|
+
result.set(a);
|
|
250
|
+
result.set(b, a.length);
|
|
251
|
+
return result;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Regroup a byte stream into chunks of exactly `size` bytes, the last one possibly smaller, like
|
|
256
|
+
* the SDK's `createChunkTransformStream`. Empty input chunks produce nothing.
|
|
257
|
+
*/
|
|
258
|
+
export const chunkBytes =
|
|
259
|
+
(size: number) =>
|
|
260
|
+
<E, R>(stream: Stream.Stream<Uint8Array, E, R>): Stream.Stream<Uint8Array, E, R> =>
|
|
261
|
+
Stream.mapAccum(
|
|
262
|
+
stream,
|
|
263
|
+
() => new Uint8Array(0),
|
|
264
|
+
(pending: Uint8Array, chunk: Uint8Array) => {
|
|
265
|
+
let buffer = pending.length === 0 ? chunk : concatBytes(pending, chunk);
|
|
266
|
+
const out: Array<Uint8Array> = [];
|
|
267
|
+
while (buffer.length >= size) {
|
|
268
|
+
out.push(buffer.slice(0, size));
|
|
269
|
+
buffer = buffer.subarray(size);
|
|
270
|
+
}
|
|
271
|
+
return [buffer, out] as const;
|
|
272
|
+
},
|
|
273
|
+
{ onHalt: (buffer) => (buffer.length > 0 ? [buffer.slice()] : []) },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const textEncoder = new TextEncoder();
|
|
277
|
+
|
|
278
|
+
const isBlob = (body: unknown): body is globalThis.Blob =>
|
|
279
|
+
typeof globalThis.Blob !== "undefined" && body instanceof globalThis.Blob;
|
|
280
|
+
|
|
281
|
+
const isStreamBody = <E, R>(body: BlobBody<E, R>): body is Stream.Stream<Uint8Array, E, R> =>
|
|
282
|
+
Stream.isStream(body);
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Bytes a body will send when known up front, as the SDK's `computeBodyLength`: UTF-8 length of a
|
|
286
|
+
* string, byte length, `Blob` size; 0 for a stream.
|
|
287
|
+
*/
|
|
288
|
+
export const bodyLength = <E, R>(body: BlobBody<E, R>): number => {
|
|
289
|
+
if (typeof body === "string") return textEncoder.encode(body).byteLength;
|
|
290
|
+
if (body instanceof Uint8Array) return body.byteLength;
|
|
291
|
+
if (isBlob(body)) return body.size;
|
|
292
|
+
return 0;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/** A body as a stream of bytes. A `Blob` that cannot be read is a defect. */
|
|
296
|
+
export const bodyStream = <E, R>(body: BlobBody<E, R>): Stream.Stream<Uint8Array, E, R> => {
|
|
297
|
+
if (typeof body === "string") return Stream.succeed(textEncoder.encode(body));
|
|
298
|
+
if (body instanceof Uint8Array) return Stream.succeed(body);
|
|
299
|
+
if (isBlob(body)) {
|
|
300
|
+
return Stream.fromReadableStream({
|
|
301
|
+
evaluate: () => body.stream(),
|
|
302
|
+
onError: (cause) => cause,
|
|
303
|
+
}).pipe(Stream.orDie);
|
|
304
|
+
}
|
|
305
|
+
return body;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const isNodeProcess = (): boolean => {
|
|
309
|
+
if (typeof navigator !== "undefined" && navigator.product === "ReactNative") return true;
|
|
310
|
+
const process = (
|
|
311
|
+
globalThis as {
|
|
312
|
+
readonly process?: { readonly type?: string; readonly versions?: { readonly node?: string } };
|
|
313
|
+
}
|
|
314
|
+
).process;
|
|
315
|
+
if (process === undefined) return false;
|
|
316
|
+
if (process.type === "renderer" || process.type === "worker") return false;
|
|
317
|
+
return process.versions?.node !== undefined;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Whether `fetch` can stream a request body here, detected like the SDK's
|
|
322
|
+
* `supportsRequestStreams`: always on Node; never against `http://localhost` (no HTTP/2); otherwise
|
|
323
|
+
* when `Request` reads the `duplex` option and does not type a stream body (Chromium, Edge
|
|
324
|
+
* runtimes, not Safari or Firefox).
|
|
325
|
+
*/
|
|
326
|
+
export const supportsRequestStreams = (apiUrl: string): boolean => {
|
|
327
|
+
if (isNodeProcess()) return true;
|
|
328
|
+
if (apiUrl.startsWith("http://localhost")) return false;
|
|
329
|
+
try {
|
|
330
|
+
let duplexAccessed = false;
|
|
331
|
+
const init = {
|
|
332
|
+
body: new ReadableStream(),
|
|
333
|
+
method: "POST",
|
|
334
|
+
get duplex() {
|
|
335
|
+
duplexAccessed = true;
|
|
336
|
+
return "half";
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
const hasContentType = new Request(apiUrl, init as RequestInit).headers.has("Content-Type");
|
|
340
|
+
return duplexAccessed && !hasContentType;
|
|
341
|
+
} catch {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** Delivers progress events, possibly throttled; see {@link uploadProgressThrottle}. */
|
|
347
|
+
export interface UploadProgressReporter {
|
|
348
|
+
readonly report: (progress: UploadProgress) => Effect.Effect<void>;
|
|
349
|
+
|
|
350
|
+
/** Drop any event still waiting for its interval and stop reporting. */
|
|
351
|
+
readonly stop: Effect.Effect<void>;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Throttle progress events like `throttleit`, which the SDK wraps `onUploadProgress` in: the first
|
|
356
|
+
* event after a quiet interval is reported at once, later ones at most once per `interval`, and the
|
|
357
|
+
* latest event of an interval is reported when it ends (earlier ones are dropped).
|
|
358
|
+
*
|
|
359
|
+
* The interval starts now, as if an event had just been reported (callers report 0 % first). Call
|
|
360
|
+
* `stop` before the final 100 % event, which callers report directly so it is never delayed.
|
|
361
|
+
* Without an interval (or a zero one), events are reported immediately.
|
|
362
|
+
*/
|
|
363
|
+
export const uploadProgressThrottle = Effect.fnUntraced(function* (
|
|
364
|
+
onUploadProgress: OnUploadProgress,
|
|
365
|
+
interval: Duration.Input | undefined,
|
|
366
|
+
): Effect.fn.Return<UploadProgressReporter> {
|
|
367
|
+
if (interval === undefined || Duration.isZero(Duration.fromInputUnsafe(interval))) {
|
|
368
|
+
return { report: onUploadProgress, stop: Effect.void };
|
|
369
|
+
}
|
|
370
|
+
const latest = yield* Queue.sliding<UploadProgress>(1);
|
|
371
|
+
const fiber = yield* Effect.forkChild(
|
|
372
|
+
Effect.sleep(interval).pipe(
|
|
373
|
+
Effect.andThen(
|
|
374
|
+
Effect.forever(
|
|
375
|
+
Queue.take(latest).pipe(
|
|
376
|
+
Effect.flatMap(onUploadProgress),
|
|
377
|
+
Effect.andThen(Effect.sleep(interval)),
|
|
378
|
+
),
|
|
379
|
+
),
|
|
380
|
+
),
|
|
381
|
+
),
|
|
382
|
+
);
|
|
383
|
+
return {
|
|
384
|
+
report: (progress) => Effect.asVoid(Queue.offer(latest, progress)),
|
|
385
|
+
stop: Fiber.interrupt(fiber),
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const unexpectedResponse = (operation: string, status: number, message: string) =>
|
|
390
|
+
new VercelApiError({
|
|
391
|
+
operation,
|
|
392
|
+
errorCode: Option.none(),
|
|
393
|
+
reason: new ApiUnexpectedResponse({ message, status }),
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const withoutTracePropagation = Effect.provideService(HttpClient.TracerPropagationEnabled, false);
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Build the {@link BlobRequester} that every service uses for Blob API calls.
|
|
400
|
+
*
|
|
401
|
+
* Requests carry no `traceparent`/`b3` headers (the SDK sends none, and browsers would need CORS
|
|
402
|
+
* approval for them); the HTTP client still records a span per attempt.
|
|
403
|
+
*/
|
|
404
|
+
export const blobRequester = (settings: BlobRequestSettings): BlobRequester => {
|
|
405
|
+
const baseUrl = settings.apiUrl.href.replace(/\/+$/, "");
|
|
406
|
+
const streamsSupported =
|
|
407
|
+
settings.streamRequestBodies ?? supportsRequestStreams(settings.apiUrl.href);
|
|
408
|
+
const client = settings.httpClient;
|
|
409
|
+
|
|
410
|
+
const requestUrl = (path: string, auth: BlobRequestAuth): string => {
|
|
411
|
+
const url = `${baseUrl}${path}`;
|
|
412
|
+
return BlobRequestAuth.$match(auth, {
|
|
413
|
+
Bearer: () => url,
|
|
414
|
+
Presigned: ({ payload }) => {
|
|
415
|
+
const presigned = new URL(url);
|
|
416
|
+
for (const [key, value] of Object.entries(payload.params)) {
|
|
417
|
+
presigned.searchParams.set(key, value);
|
|
418
|
+
}
|
|
419
|
+
presigned.searchParams.set("vercel-blob-delegation", payload.delegationToken);
|
|
420
|
+
presigned.searchParams.set("vercel-blob-signature", payload.signature);
|
|
421
|
+
return presigned.toString();
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const call = Effect.fnUntraced(function* <
|
|
427
|
+
A,
|
|
428
|
+
E,
|
|
429
|
+
R,
|
|
430
|
+
EA,
|
|
431
|
+
RA,
|
|
432
|
+
K extends BlobDomainErrorTag = BlobDomainErrorTag,
|
|
433
|
+
>(call: BlobApiCall<A, E, R, EA, RA, K>): Effect.fn.Return<A, BlobApiError<K> | E | EA, R | RA> {
|
|
434
|
+
const { operation } = call;
|
|
435
|
+
const auth = yield* call.auth;
|
|
436
|
+
const url = requestUrl(call.path, auth);
|
|
437
|
+
const now = yield* Clock.currentTimeMillis;
|
|
438
|
+
const random = yield* Random.next;
|
|
439
|
+
const requestId = `${auth.storeId}:${now}:${random.toString(16).slice(2)}`;
|
|
440
|
+
|
|
441
|
+
const body: BlobBody<E, R> | undefined =
|
|
442
|
+
call.json === undefined ? call.body : JSON.stringify(call.json);
|
|
443
|
+
const onUploadProgress = call.onUploadProgress;
|
|
444
|
+
const sendBodyLength = onUploadProgress !== undefined || settings.sendContentLength;
|
|
445
|
+
const totalLength = body !== undefined && sendBodyLength ? bodyLength(body) : 0;
|
|
446
|
+
const streamBody = body !== undefined && isStreamBody(body);
|
|
447
|
+
|
|
448
|
+
const context = yield* Effect.context<R>();
|
|
449
|
+
const bodyFailure = yield* Ref.make(Option.none<E>());
|
|
450
|
+
const totalLoaded = yield* Ref.make(0);
|
|
451
|
+
|
|
452
|
+
const userStream = (stream: Stream.Stream<Uint8Array, E, R>): Stream.Stream<Uint8Array> =>
|
|
453
|
+
stream.pipe(
|
|
454
|
+
Stream.tapError((error) => Ref.set(bodyFailure, Option.some(error))),
|
|
455
|
+
Stream.provideContext(context),
|
|
456
|
+
) as Stream.Stream<Uint8Array>;
|
|
457
|
+
|
|
458
|
+
const headersFor = (attempt: number): Record<string, string> => ({
|
|
459
|
+
"x-api-blob-request-id": requestId,
|
|
460
|
+
"x-vercel-blob-store-id": auth.storeId,
|
|
461
|
+
"x-api-blob-request-attempt": String(attempt),
|
|
462
|
+
"x-api-version": settings.apiVersion,
|
|
463
|
+
...(sendBodyLength ? { "x-content-length": String(totalLength) } : {}),
|
|
464
|
+
...BlobRequestAuth.$match(auth, {
|
|
465
|
+
Bearer: ({ token }) => ({ authorization: `Bearer ${Redacted.value(token)}` }),
|
|
466
|
+
Presigned: () => ({}),
|
|
467
|
+
}),
|
|
468
|
+
...settings.headers,
|
|
469
|
+
...(call.json === undefined ? {} : { "content-type": "application/json" }),
|
|
470
|
+
...call.headers,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// A streamed body is sent untyped, like the SDK's `ReadableStream` bodies.
|
|
474
|
+
const streamedBody =
|
|
475
|
+
(stream: Stream.Stream<Uint8Array>) => (request: HttpClientRequest.HttpClientRequest) =>
|
|
476
|
+
request.pipe(
|
|
477
|
+
HttpClientRequest.setBody(HttpBody.stream(stream)),
|
|
478
|
+
HttpClientRequest.removeHeader("content-type"),
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const withBody = (
|
|
482
|
+
reporter: UploadProgressReporter | undefined,
|
|
483
|
+
): Effect.Effect<
|
|
484
|
+
(request: HttpClientRequest.HttpClientRequest) => HttpClientRequest.HttpClientRequest,
|
|
485
|
+
E
|
|
486
|
+
> =>
|
|
487
|
+
Effect.gen(function* () {
|
|
488
|
+
if (body === undefined) {
|
|
489
|
+
return (request) => request;
|
|
490
|
+
}
|
|
491
|
+
if (!streamsSupported) {
|
|
492
|
+
const bytes = streamBody
|
|
493
|
+
? (yield* Stream.runCollect(Stream.provideContext(body, context))).reduce(
|
|
494
|
+
concatBytes,
|
|
495
|
+
new Uint8Array(0),
|
|
496
|
+
)
|
|
497
|
+
: undefined;
|
|
498
|
+
yield* Ref.set(totalLoaded, bytes === undefined ? totalLength : bytes.length);
|
|
499
|
+
return HttpClientRequest.setBody(HttpBody.raw(bytes ?? body));
|
|
500
|
+
}
|
|
501
|
+
if (reporter !== undefined) {
|
|
502
|
+
const loaded = yield* Ref.make(0);
|
|
503
|
+
const source = streamBody
|
|
504
|
+
? userStream(body)
|
|
505
|
+
: (bodyStream(body) as Stream.Stream<Uint8Array>);
|
|
506
|
+
const tracked = source.pipe(
|
|
507
|
+
chunkBytes(progressChunkSize),
|
|
508
|
+
Stream.tap((chunk) =>
|
|
509
|
+
Effect.gen(function* () {
|
|
510
|
+
const sent = yield* Ref.updateAndGet(loaded, (n) => n + chunk.length);
|
|
511
|
+
yield* Ref.set(totalLoaded, sent);
|
|
512
|
+
const total = totalLength !== 0 ? totalLength : sent;
|
|
513
|
+
const percentage = totalLength > 0 ? Number(((sent / total) * 100).toFixed(2)) : 0;
|
|
514
|
+
if (percentage === 100 && totalLength > 0) return;
|
|
515
|
+
yield* reporter.report({ loaded: sent, total, percentage });
|
|
516
|
+
}),
|
|
517
|
+
),
|
|
518
|
+
);
|
|
519
|
+
return streamedBody(tracked);
|
|
520
|
+
}
|
|
521
|
+
if (streamBody) {
|
|
522
|
+
return streamedBody(userStream(body));
|
|
523
|
+
}
|
|
524
|
+
return HttpClientRequest.setBody(HttpBody.raw(body));
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const failTransport = (error: HttpClientError.HttpClientError) =>
|
|
528
|
+
Effect.flatMap(
|
|
529
|
+
Ref.get(bodyFailure),
|
|
530
|
+
Option.match({
|
|
531
|
+
onNone: () => vercelApiErrorFromHttpClientError(error, { operation }),
|
|
532
|
+
onSome: (failure): Effect.Effect<never, E | VercelApiError> => Effect.fail(failure),
|
|
533
|
+
}),
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const attempts = yield* Ref.make(0);
|
|
537
|
+
const attempt = (reporter: UploadProgressReporter | undefined) =>
|
|
538
|
+
Effect.gen(function* () {
|
|
539
|
+
const index = yield* Ref.getAndUpdate(attempts, (n) => n + 1);
|
|
540
|
+
const setBody = yield* withBody(reporter);
|
|
541
|
+
const request = HttpClientRequest.make(call.method)(url).pipe(
|
|
542
|
+
setBody,
|
|
543
|
+
HttpClientRequest.setHeaders(headersFor(index)),
|
|
544
|
+
);
|
|
545
|
+
const response = yield* client
|
|
546
|
+
.execute(request)
|
|
547
|
+
.pipe(Effect.catchTag("HttpClientError", failTransport));
|
|
548
|
+
if (response.status >= 200 && response.status < 300) {
|
|
549
|
+
return response;
|
|
550
|
+
}
|
|
551
|
+
return yield* blobErrorFromHttpResponse(response, {
|
|
552
|
+
operation,
|
|
553
|
+
locator: call.locator ?? call.path,
|
|
554
|
+
domainErrors: call.domainErrors,
|
|
555
|
+
});
|
|
556
|
+
}).pipe(
|
|
557
|
+
Effect.retry(
|
|
558
|
+
vercelApiTransientRetry({
|
|
559
|
+
maxAttempts: streamBody ? 1 : settings.retries + 1,
|
|
560
|
+
baseDelay: "1 second",
|
|
561
|
+
isRetryable: isRetryableBlobError,
|
|
562
|
+
}),
|
|
563
|
+
),
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
const response =
|
|
567
|
+
onUploadProgress === undefined
|
|
568
|
+
? yield* attempt(undefined)
|
|
569
|
+
: yield* Effect.gen(function* () {
|
|
570
|
+
yield* onUploadProgress({ loaded: 0, total: totalLength, percentage: 0 });
|
|
571
|
+
const reporter = yield* uploadProgressThrottle(onUploadProgress, call.progressInterval);
|
|
572
|
+
const response = yield* attempt(reporter).pipe(Effect.ensuring(reporter.stop));
|
|
573
|
+
const loaded = yield* Ref.get(totalLoaded);
|
|
574
|
+
yield* onUploadProgress({ loaded, total: loaded, percentage: 100 });
|
|
575
|
+
return response;
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
if (call.response === undefined) {
|
|
579
|
+
yield* Effect.ignore(response.arrayBuffer);
|
|
580
|
+
return undefined as A;
|
|
581
|
+
}
|
|
582
|
+
const json = yield* response.json.pipe(
|
|
583
|
+
Effect.mapError((error) => unexpectedResponse(operation, response.status, error.message)),
|
|
584
|
+
);
|
|
585
|
+
return yield* Schema.decodeUnknownEffect(call.response)(json).pipe(
|
|
586
|
+
Effect.mapError((error) =>
|
|
587
|
+
unexpectedResponse(
|
|
588
|
+
operation,
|
|
589
|
+
response.status,
|
|
590
|
+
`Unexpected response body: ${error.message}`,
|
|
591
|
+
),
|
|
592
|
+
),
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const fetch = (request: BlobFetchRequest) =>
|
|
597
|
+
client
|
|
598
|
+
.execute(
|
|
599
|
+
HttpClientRequest.get(request.url).pipe(
|
|
600
|
+
HttpClientRequest.setHeaders({
|
|
601
|
+
authorization: `Bearer ${Redacted.value(request.token)}`,
|
|
602
|
+
...request.headers,
|
|
603
|
+
}),
|
|
604
|
+
),
|
|
605
|
+
)
|
|
606
|
+
.pipe(
|
|
607
|
+
Effect.catchTag("HttpClientError", (error) =>
|
|
608
|
+
vercelApiErrorFromHttpClientError(error, { operation: request.operation }),
|
|
609
|
+
),
|
|
610
|
+
withoutTracePropagation,
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
call: (request) => withoutTracePropagation(call(request)),
|
|
615
|
+
fetch,
|
|
616
|
+
};
|
|
617
|
+
};
|