@panorama-industries/effect-vercel-blob 0.0.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +33 -1
- package/dist/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +8 -0
- package/dist/client.js.map +1 -0
- package/dist/contracts/blob-client.d.ts +67 -0
- package/dist/contracts/blob-client.d.ts.map +1 -0
- package/dist/contracts/blob-client.js +11 -0
- package/dist/contracts/blob-client.js.map +1 -0
- package/dist/contracts/blob-config.d.ts +114 -0
- package/dist/contracts/blob-config.d.ts.map +1 -0
- package/dist/contracts/blob-config.js +7 -0
- package/dist/contracts/blob-config.js.map +1 -0
- package/dist/contracts/blob-domain.d.ts +336 -0
- package/dist/contracts/blob-domain.d.ts.map +1 -0
- package/dist/contracts/blob-domain.js +152 -0
- package/dist/contracts/blob-domain.js.map +1 -0
- package/dist/contracts/blob-errors.d.ts +125 -0
- package/dist/contracts/blob-errors.d.ts.map +1 -0
- package/dist/contracts/blob-errors.js +114 -0
- package/dist/contracts/blob-errors.js.map +1 -0
- package/dist/contracts/blob-signed-urls.d.ts +83 -0
- package/dist/contracts/blob-signed-urls.d.ts.map +1 -0
- package/dist/contracts/blob-signed-urls.js +10 -0
- package/dist/contracts/blob-signed-urls.js.map +1 -0
- package/dist/contracts/blob-store.d.ts +153 -0
- package/dist/contracts/blob-store.d.ts.map +1 -0
- package/dist/contracts/blob-store.js +15 -0
- package/dist/contracts/blob-store.js.map +1 -0
- package/dist/contracts/blob-uploads.d.ts +101 -0
- package/dist/contracts/blob-uploads.d.ts.map +1 -0
- package/dist/contracts/blob-uploads.js +17 -0
- package/dist/contracts/blob-uploads.js.map +1 -0
- package/dist/implementation/blob-client-live.d.ts +37 -0
- package/dist/implementation/blob-client-live.d.ts.map +1 -0
- package/dist/implementation/blob-client-live.js +213 -0
- package/dist/implementation/blob-client-live.js.map +1 -0
- package/dist/implementation/blob-config-live.d.ts +49 -0
- package/dist/implementation/blob-config-live.d.ts.map +1 -0
- package/dist/implementation/blob-config-live.js +82 -0
- package/dist/implementation/blob-config-live.js.map +1 -0
- package/dist/implementation/blob-live.d.ts +26 -0
- package/dist/implementation/blob-live.d.ts.map +1 -0
- package/dist/implementation/blob-live.js +26 -0
- package/dist/implementation/blob-live.js.map +1 -0
- package/dist/implementation/blob-signed-urls-live.d.ts +12 -0
- package/dist/implementation/blob-signed-urls-live.d.ts.map +1 -0
- package/dist/implementation/blob-signed-urls-live.js +92 -0
- package/dist/implementation/blob-signed-urls-live.js.map +1 -0
- package/dist/implementation/blob-store-live.d.ts +18 -0
- package/dist/implementation/blob-store-live.d.ts.map +1 -0
- package/dist/implementation/blob-store-live.js +364 -0
- package/dist/implementation/blob-store-live.js.map +1 -0
- package/dist/implementation/blob-uploads-live.d.ts +11 -0
- package/dist/implementation/blob-uploads-live.d.ts.map +1 -0
- package/dist/implementation/blob-uploads-live.js +218 -0
- package/dist/implementation/blob-uploads-live.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/blob-errors.d.ts +79 -0
- package/dist/internal/blob-errors.d.ts.map +1 -0
- package/dist/internal/blob-errors.js +196 -0
- package/dist/internal/blob-errors.js.map +1 -0
- package/dist/internal/blob-request.d.ts +255 -0
- package/dist/internal/blob-request.d.ts.map +1 -0
- package/dist/internal/blob-request.js +291 -0
- package/dist/internal/blob-request.js.map +1 -0
- package/dist/internal/callback.d.ts +70 -0
- package/dist/internal/callback.d.ts.map +1 -0
- package/dist/internal/callback.js +141 -0
- package/dist/internal/callback.js.map +1 -0
- package/dist/internal/client-token.d.ts +65 -0
- package/dist/internal/client-token.d.ts.map +1 -0
- package/dist/internal/client-token.js +80 -0
- package/dist/internal/client-token.js.map +1 -0
- package/dist/internal/env.d.ts +34 -0
- package/dist/internal/env.d.ts.map +1 -0
- package/dist/internal/env.js +61 -0
- package/dist/internal/env.js.map +1 -0
- package/dist/internal/multipart.d.ts +110 -0
- package/dist/internal/multipart.d.ts.map +1 -0
- package/dist/internal/multipart.js +290 -0
- package/dist/internal/multipart.js.map +1 -0
- package/dist/internal/presign.d.ts +123 -0
- package/dist/internal/presign.d.ts.map +1 -0
- package/dist/internal/presign.js +345 -0
- package/dist/internal/presign.js.map +1 -0
- package/dist/internal/store-id.d.ts +38 -0
- package/dist/internal/store-id.d.ts.map +1 -0
- package/dist/internal/store-id.js +54 -0
- package/dist/internal/store-id.js.map +1 -0
- package/dist/internal/urls.d.ts +26 -0
- package/dist/internal/urls.d.ts.map +1 -0
- package/dist/internal/urls.js +33 -0
- package/dist/internal/urls.js.map +1 -0
- package/dist/internal/wire.d.ts +55 -0
- package/dist/internal/wire.d.ts.map +1 -0
- package/dist/internal/wire.js +64 -0
- package/dist/internal/wire.js.map +1 -0
- package/dist/internal/write-headers.d.ts +56 -0
- package/dist/internal/write-headers.d.ts.map +1 -0
- package/dist/internal/write-headers.js +82 -0
- package/dist/internal/write-headers.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +2 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/test-blob-store.d.ts +134 -0
- package/dist/testing/test-blob-store.d.ts.map +1 -0
- package/dist/testing/test-blob-store.js +718 -0
- package/dist/testing/test-blob-store.js.map +1 -0
- package/package.json +58 -4
- package/src/client.ts +11 -0
- package/src/contracts/blob-client.ts +121 -0
- package/src/contracts/blob-config.ts +64 -0
- package/src/contracts/blob-domain.ts +304 -0
- package/src/contracts/blob-errors.ts +161 -0
- package/src/contracts/blob-signed-urls.ts +112 -0
- package/src/contracts/blob-store.ts +258 -0
- package/src/contracts/blob-uploads.ts +151 -0
- package/src/implementation/blob-client-live.ts +387 -0
- package/src/implementation/blob-config-live.ts +145 -0
- package/src/implementation/blob-live.ts +55 -0
- package/src/implementation/blob-signed-urls-live.ts +121 -0
- package/src/implementation/blob-store-live.ts +528 -0
- package/src/implementation/blob-uploads-live.ts +326 -0
- package/src/index.ts +18 -0
- package/src/internal/blob-errors.ts +325 -0
- package/src/internal/blob-request.ts +617 -0
- package/src/internal/callback.ts +200 -0
- package/src/internal/client-token.ts +132 -0
- package/src/internal/env.ts +128 -0
- package/src/internal/multipart.ts +526 -0
- package/src/internal/presign.ts +477 -0
- package/src/internal/store-id.ts +69 -0
- package/src/internal/urls.ts +42 -0
- package/src/internal/wire.ts +80 -0
- package/src/internal/write-headers.ts +118 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/test-blob-store.ts +1136 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { ApiBadRequest, VercelApiError, VercelOidc } from "@panorama-industries/effect-vercel-core";
|
|
2
|
+
import { Clock, DateTime, Effect, Layer, Option, Schema } from "effect";
|
|
3
|
+
import { HttpClient } from "effect/http";
|
|
4
|
+
|
|
5
|
+
import type { BlobAccess } from "../contracts/blob-domain.ts";
|
|
6
|
+
import type {
|
|
7
|
+
IssueSignedTokenOptions,
|
|
8
|
+
PresignOptions,
|
|
9
|
+
SigningToken,
|
|
10
|
+
} from "../contracts/blob-signed-urls.ts";
|
|
11
|
+
|
|
12
|
+
import { BlobConfig } from "../contracts/blob-config.ts";
|
|
13
|
+
import { SignedToken } from "../contracts/blob-domain.ts";
|
|
14
|
+
import { BlobPresignFailed } from "../contracts/blob-errors.ts";
|
|
15
|
+
import { BlobSignedUrls } from "../contracts/blob-signed-urls.ts";
|
|
16
|
+
import { blobRequester, blobRequestSettings, serverBlobAuth } from "../internal/blob-request.ts";
|
|
17
|
+
import { presignedApiUrl, presignedBlobUrl, presignPayload } from "../internal/presign.ts";
|
|
18
|
+
|
|
19
|
+
/** `POST /signed-token`; `validUntil` is in epoch milliseconds. */
|
|
20
|
+
const SignedTokenResponse = Schema.Struct({
|
|
21
|
+
delegationToken: Schema.String,
|
|
22
|
+
clientSigningToken: Schema.RedactedFromValue(Schema.String),
|
|
23
|
+
validUntil: Schema.DateTimeUtcFromMillis,
|
|
24
|
+
}).pipe(Schema.decodeTo(SignedToken));
|
|
25
|
+
|
|
26
|
+
/** The JSON body of `POST /signed-token`, with the SDK's keys in the SDK's order. */
|
|
27
|
+
const signedTokenBody = (options: IssueSignedTokenOptions): Record<string, unknown> => ({
|
|
28
|
+
...(options.pathname === undefined ? {} : { pathname: options.pathname }),
|
|
29
|
+
...(options.operations === undefined ? {} : { operations: [...new Set(options.operations)] }),
|
|
30
|
+
...(options.validUntil === undefined
|
|
31
|
+
? {}
|
|
32
|
+
: { validUntil: DateTime.toEpochMillis(options.validUntil) }),
|
|
33
|
+
...(options.maximumSizeInBytes === undefined
|
|
34
|
+
? {}
|
|
35
|
+
: { maximumSizeInBytes: options.maximumSizeInBytes }),
|
|
36
|
+
...(options.allowedContentTypes === undefined
|
|
37
|
+
? {}
|
|
38
|
+
: { allowedContentTypes: options.allowedContentTypes }),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Implements {@link BlobSignedUrls}: `issueSignedToken` calls the Blob API with the server
|
|
43
|
+
* credentials of `BlobConfig` (OIDC or the read-write token); presigning is local and reads the
|
|
44
|
+
* current time from `Clock`.
|
|
45
|
+
*/
|
|
46
|
+
export const BlobSignedUrlsLive: Layer.Layer<
|
|
47
|
+
BlobSignedUrls,
|
|
48
|
+
never,
|
|
49
|
+
BlobConfig | HttpClient.HttpClient | VercelOidc
|
|
50
|
+
> = Layer.effect(
|
|
51
|
+
BlobSignedUrls,
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
const config = yield* BlobConfig;
|
|
54
|
+
const httpClient = yield* HttpClient.HttpClient;
|
|
55
|
+
const oidc = yield* VercelOidc;
|
|
56
|
+
const requester = blobRequester(blobRequestSettings(config, httpClient));
|
|
57
|
+
|
|
58
|
+
const issueSignedToken = Effect.fn("BlobSignedUrls.issueSignedToken")(function* (
|
|
59
|
+
options: IssueSignedTokenOptions = {},
|
|
60
|
+
) {
|
|
61
|
+
if (options.validUntil !== undefined) {
|
|
62
|
+
const now = yield* Clock.currentTimeMillis;
|
|
63
|
+
if (DateTime.toEpochMillis(options.validUntil) <= now) {
|
|
64
|
+
return yield* new VercelApiError({
|
|
65
|
+
operation: "BlobSignedUrls.issueSignedToken",
|
|
66
|
+
errorCode: Option.none(),
|
|
67
|
+
reason: new ApiBadRequest({
|
|
68
|
+
message: "`issueSignedToken`: validUntil must be in the future.",
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return yield* requester.call({
|
|
74
|
+
operation: "BlobSignedUrls.issueSignedToken",
|
|
75
|
+
method: "POST",
|
|
76
|
+
path: "/signed-token",
|
|
77
|
+
auth: serverBlobAuth(config, oidc),
|
|
78
|
+
json: signedTokenBody(options),
|
|
79
|
+
response: SignedTokenResponse,
|
|
80
|
+
domainErrors: ["BlobStoreUnavailable", "BlobOidcEnvironmentNotAllowed"],
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const presign = Effect.fn("BlobSignedUrls.presign")(function* (
|
|
85
|
+
token: SigningToken,
|
|
86
|
+
options: PresignOptions,
|
|
87
|
+
) {
|
|
88
|
+
return yield* presignPayload(token, options, yield* Clock.currentTimeMillis);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const presignUrl = Effect.fn("BlobSignedUrls.presignUrl")(function* (
|
|
92
|
+
token: SigningToken,
|
|
93
|
+
options: PresignOptions & { readonly access: BlobAccess },
|
|
94
|
+
) {
|
|
95
|
+
const payload = yield* presignPayload(token, options, yield* Clock.currentTimeMillis);
|
|
96
|
+
switch (options.operation) {
|
|
97
|
+
case "get":
|
|
98
|
+
case "head": {
|
|
99
|
+
const url = presignedBlobUrl({
|
|
100
|
+
pathnameOrUrl: options.pathname,
|
|
101
|
+
payload,
|
|
102
|
+
access: options.access,
|
|
103
|
+
useCache: options.operation === "get" ? options.useCache : undefined,
|
|
104
|
+
blobBaseUrl: config.blobBaseUrl,
|
|
105
|
+
});
|
|
106
|
+
if (Option.isNone(url)) {
|
|
107
|
+
return yield* new BlobPresignFailed({
|
|
108
|
+
message: "Delegation token payload is missing `storeId`.",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return url.value;
|
|
112
|
+
}
|
|
113
|
+
case "put":
|
|
114
|
+
case "delete":
|
|
115
|
+
return presignedApiUrl(config.apiUrl, options.pathname, payload);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return BlobSignedUrls.of({ issueSignedToken, presign, presignUrl });
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import type { OidcTokenUnavailable } from "@panorama-industries/effect-vercel-core";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ApiNetworkFailure,
|
|
5
|
+
VercelApiError,
|
|
6
|
+
VercelOidc,
|
|
7
|
+
vercelApiErrorFromStatus,
|
|
8
|
+
} from "@panorama-industries/effect-vercel-core";
|
|
9
|
+
import { Clock, DateTime, Effect, Layer, Option, Stream } from "effect";
|
|
10
|
+
import { Headers, HttpClient } from "effect/http";
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
BlobAccess,
|
|
14
|
+
BlobBody,
|
|
15
|
+
BlobLocator,
|
|
16
|
+
BlobPathname,
|
|
17
|
+
CreateBlobOptions,
|
|
18
|
+
ETag,
|
|
19
|
+
MultipartUpload,
|
|
20
|
+
PartNumber,
|
|
21
|
+
StoreId,
|
|
22
|
+
UploadedPart,
|
|
23
|
+
} from "../contracts/blob-domain.ts";
|
|
24
|
+
import type {
|
|
25
|
+
BlobWriteError,
|
|
26
|
+
CreateMultipartUploadOptions,
|
|
27
|
+
GetOptions,
|
|
28
|
+
ImageFormat,
|
|
29
|
+
ImageOptimization,
|
|
30
|
+
ListOptions,
|
|
31
|
+
PutImageOptions,
|
|
32
|
+
PutOptions,
|
|
33
|
+
} from "../contracts/blob-store.ts";
|
|
34
|
+
import type { BlobRequester } from "../internal/blob-request.ts";
|
|
35
|
+
|
|
36
|
+
import { BlobConfig } from "../contracts/blob-config.ts";
|
|
37
|
+
import { FetchedBlob, GetBlobResult, PutBlobResult } from "../contracts/blob-domain.ts";
|
|
38
|
+
import {
|
|
39
|
+
BlobNotFound,
|
|
40
|
+
BlobUploadRejected,
|
|
41
|
+
ContentTypeNotAllowed,
|
|
42
|
+
} from "../contracts/blob-errors.ts";
|
|
43
|
+
import { BlobStore } from "../contracts/blob-store.ts";
|
|
44
|
+
import {
|
|
45
|
+
BlobRequestAuth,
|
|
46
|
+
blobRequester,
|
|
47
|
+
blobRequestSettings,
|
|
48
|
+
serverBlobAuth,
|
|
49
|
+
} from "../internal/blob-request.ts";
|
|
50
|
+
import * as Multipart from "../internal/multipart.ts";
|
|
51
|
+
import { blobUrl, downloadUrl, isAllowedBlobUrl, isUrl } from "../internal/urls.ts";
|
|
52
|
+
import {
|
|
53
|
+
CreateFolderResponse,
|
|
54
|
+
HeadBlobResponse,
|
|
55
|
+
ListResponse,
|
|
56
|
+
PutBlobResponse,
|
|
57
|
+
} from "../internal/wire.ts";
|
|
58
|
+
import { allowedWriteOptions, copyWriteHeaders, writeHeaders } from "../internal/write-headers.ts";
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* Domain errors each operation reports, matching the error unions of its contract. Every other
|
|
62
|
+
* domain code an error body carries is reported as `VercelApiError` for its status.
|
|
63
|
+
*/
|
|
64
|
+
const requestErrors = ["BlobStoreUnavailable", "BlobOidcEnvironmentNotAllowed"] as const;
|
|
65
|
+
const readErrors = [...requestErrors, "BlobNotFound"] as const;
|
|
66
|
+
const deleteErrors = [...requestErrors, "BlobPreconditionFailed"] as const;
|
|
67
|
+
const writeErrors = [...requestErrors, "BlobPreconditionFailed", "BlobUploadRejected"] as const;
|
|
68
|
+
const copyErrors = [...writeErrors, "BlobNotFound"] as const;
|
|
69
|
+
|
|
70
|
+
/** `put` progress events are throttled like the SDK's `throttle(onUploadProgress, 100)`. */
|
|
71
|
+
const putProgressInterval = "100 millis";
|
|
72
|
+
|
|
73
|
+
/** The SDK's `optimizeImageFormatToMimeType`: `format` is sent as the output mime type. */
|
|
74
|
+
const imageFormatMimeType: Record<ImageFormat, string> = {
|
|
75
|
+
jpeg: "image/jpeg",
|
|
76
|
+
png: "image/png",
|
|
77
|
+
webp: "image/webp",
|
|
78
|
+
avif: "image/avif",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const defaultImageQuality = 75;
|
|
82
|
+
|
|
83
|
+
/** The SDK's `addOptimizeImageParams`; the branded option types already hold its validation. */
|
|
84
|
+
const addImageParams = (params: URLSearchParams, optimization: ImageOptimization): void => {
|
|
85
|
+
params.set("width", String(optimization.width));
|
|
86
|
+
params.set("quality", String(optimization.quality ?? defaultImageQuality));
|
|
87
|
+
if (optimization.format !== undefined) {
|
|
88
|
+
params.set("format", imageFormatMimeType[optimization.format]);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const isBlob = (body: unknown): body is globalThis.Blob =>
|
|
93
|
+
typeof globalThis.Blob !== "undefined" && body instanceof globalThis.Blob;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The SDK's `validateOptimizeImageSourceContentType`: a known media type that is neither an image
|
|
97
|
+
* nor `application/octet-stream` cannot be optimized. Unknown types are left to the server.
|
|
98
|
+
*/
|
|
99
|
+
const nonImageMediaType = (mediaType: string): boolean =>
|
|
100
|
+
mediaType !== "" && mediaType !== "application/octet-stream" && !mediaType.startsWith("image/");
|
|
101
|
+
|
|
102
|
+
const locatorString = (locator: BlobLocator): string =>
|
|
103
|
+
typeof locator === "string" ? locator : locator.href;
|
|
104
|
+
|
|
105
|
+
/** A defect carrying the SDK's `BlobError` message, for input the SDK rejects by throwing. */
|
|
106
|
+
const invalidInput = (message: string) => Effect.die(new Error(`Vercel Blob: ${message}`));
|
|
107
|
+
|
|
108
|
+
const notBlobStoreUrl =
|
|
109
|
+
"Invalid URL: the URL does not point to a Vercel Blob store. Use a pathname instead, see https://vercel.com/docs/vercel-blob";
|
|
110
|
+
|
|
111
|
+
const header = (headers: Headers.Headers, name: string): string | undefined =>
|
|
112
|
+
Option.getOrUndefined(Headers.get(headers, name));
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Multipart operations of {@link BlobStoreLive}, over the shared engine in `internal/multipart.ts`
|
|
116
|
+
* with the server's write headers and domain errors.
|
|
117
|
+
*/
|
|
118
|
+
interface MultipartOperations {
|
|
119
|
+
/** `put(..., { multipart: true })`: automatic multipart over the body stream. */
|
|
120
|
+
readonly put: <E, R>(
|
|
121
|
+
pathname: BlobPathname,
|
|
122
|
+
body: BlobBody<E, R>,
|
|
123
|
+
options: PutOptions,
|
|
124
|
+
) => Effect.Effect<PutBlobResult, BlobWriteError | E, R>;
|
|
125
|
+
readonly createMultipartUpload: BlobStore["Service"]["createMultipartUpload"];
|
|
126
|
+
readonly uploadPart: BlobStore["Service"]["uploadPart"];
|
|
127
|
+
readonly completeMultipartUpload: BlobStore["Service"]["completeMultipartUpload"];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const multipartOperations = (dependencies: {
|
|
131
|
+
readonly requester: BlobRequester;
|
|
132
|
+
readonly auth: Effect.Effect<BlobRequestAuth, OidcTokenUnavailable>;
|
|
133
|
+
}): MultipartOperations => {
|
|
134
|
+
const target: Multipart.MultipartTarget<
|
|
135
|
+
OidcTokenUnavailable,
|
|
136
|
+
never,
|
|
137
|
+
(typeof writeErrors)[number]
|
|
138
|
+
> = {
|
|
139
|
+
requester: dependencies.requester,
|
|
140
|
+
auth: dependencies.auth,
|
|
141
|
+
service: "BlobStore",
|
|
142
|
+
domainErrors: writeErrors,
|
|
143
|
+
writeOptions: Multipart.serverMultipartWriteOptions,
|
|
144
|
+
};
|
|
145
|
+
return {
|
|
146
|
+
put: (pathname, body, options) => Multipart.multipartPut(target, pathname, body, options),
|
|
147
|
+
createMultipartUpload: Effect.fn("BlobStore.createMultipartUpload")(function* (
|
|
148
|
+
pathname: BlobPathname,
|
|
149
|
+
options: CreateMultipartUploadOptions,
|
|
150
|
+
) {
|
|
151
|
+
return yield* Multipart.createMultipartUpload(target, pathname, options);
|
|
152
|
+
}),
|
|
153
|
+
uploadPart: Effect.fn("BlobStore.uploadPart")(function* <E = never, R = never>(
|
|
154
|
+
upload: MultipartUpload,
|
|
155
|
+
partNumber: PartNumber,
|
|
156
|
+
body: BlobBody<E, R>,
|
|
157
|
+
): Effect.fn.Return<UploadedPart, BlobWriteError | E, R> {
|
|
158
|
+
return yield* Multipart.uploadPart(target, upload, partNumber, body);
|
|
159
|
+
}),
|
|
160
|
+
completeMultipartUpload: Effect.fn("BlobStore.completeMultipartUpload")(function* (
|
|
161
|
+
upload: MultipartUpload,
|
|
162
|
+
parts: ReadonlyArray<UploadedPart>,
|
|
163
|
+
) {
|
|
164
|
+
return yield* Multipart.completeMultipartUpload(target, upload, parts);
|
|
165
|
+
}),
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Implements {@link BlobStore} over the Blob API with the SDK's wire protocol, retries and error
|
|
171
|
+
* mapping. Requires `BlobConfig`, an `HttpClient`, and `VercelOidc` (evaluated per request when
|
|
172
|
+
* `BlobConfig.auth` is OIDC).
|
|
173
|
+
*
|
|
174
|
+
* `get` reads from the blob's host. A URL locator must pass the SDK's `isAllowedBlobUrl` check (a
|
|
175
|
+
* `*.blob.vercel-storage.com` host, or the origin of an overridden API URL); any other URL, or a
|
|
176
|
+
* string that starts with `http(s)://` but does not parse, is a defect, as are an empty `get`
|
|
177
|
+
* locator and a `putImage` source `URL` that is not http(s). These are programming errors the SDK
|
|
178
|
+
* throws `BlobError` for.
|
|
179
|
+
*/
|
|
180
|
+
export const BlobStoreLive: Layer.Layer<
|
|
181
|
+
BlobStore,
|
|
182
|
+
never,
|
|
183
|
+
BlobConfig | HttpClient.HttpClient | VercelOidc
|
|
184
|
+
> = Layer.effect(
|
|
185
|
+
BlobStore,
|
|
186
|
+
Effect.gen(function* () {
|
|
187
|
+
const config = yield* BlobConfig;
|
|
188
|
+
const httpClient = yield* HttpClient.HttpClient;
|
|
189
|
+
const oidc = yield* VercelOidc;
|
|
190
|
+
const requester = blobRequester(blobRequestSettings(config, httpClient));
|
|
191
|
+
const auth = serverBlobAuth(config, oidc);
|
|
192
|
+
const multipart = multipartOperations({ requester, auth });
|
|
193
|
+
|
|
194
|
+
const put = Effect.fn("BlobStore.put")(function* <E = never, R = never>(
|
|
195
|
+
pathname: BlobPathname,
|
|
196
|
+
body: BlobBody<E, R>,
|
|
197
|
+
options: PutOptions,
|
|
198
|
+
): Effect.fn.Return<PutBlobResult, BlobWriteError | E, R> {
|
|
199
|
+
if (options.multipart === true) {
|
|
200
|
+
return yield* multipart.put(pathname, body, options);
|
|
201
|
+
}
|
|
202
|
+
const query = new URLSearchParams({ pathname });
|
|
203
|
+
return yield* requester.call({
|
|
204
|
+
operation: "BlobStore.put",
|
|
205
|
+
method: "PUT",
|
|
206
|
+
path: `/?${query.toString()}`,
|
|
207
|
+
auth,
|
|
208
|
+
response: PutBlobResponse,
|
|
209
|
+
headers: writeHeaders(allowedWriteOptions.put, options),
|
|
210
|
+
body,
|
|
211
|
+
locator: pathname,
|
|
212
|
+
domainErrors: writeErrors,
|
|
213
|
+
onUploadProgress: options.onUploadProgress,
|
|
214
|
+
progressInterval: putProgressInterval,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const putImage = Effect.fn("BlobStore.putImage")(function* <E = never, R = never>(
|
|
219
|
+
pathname: BlobPathname,
|
|
220
|
+
source: BlobBody<E, R> | URL,
|
|
221
|
+
options: PutImageOptions,
|
|
222
|
+
): Effect.fn.Return<PutBlobResult, BlobWriteError | E, R> {
|
|
223
|
+
const headers = writeHeaders(allowedWriteOptions.putImage, options);
|
|
224
|
+
if (source instanceof URL) {
|
|
225
|
+
if (source.protocol !== "http:" && source.protocol !== "https:") {
|
|
226
|
+
return yield* invalidInput("the source URL must use the http(s) protocol");
|
|
227
|
+
}
|
|
228
|
+
const query = new URLSearchParams({ pathname, url: source.toString() });
|
|
229
|
+
addImageParams(query, options.optimizeImage);
|
|
230
|
+
return yield* requester.call({
|
|
231
|
+
operation: "BlobStore.putImage",
|
|
232
|
+
method: "POST",
|
|
233
|
+
path: `/put-from-url?${query.toString()}`,
|
|
234
|
+
auth,
|
|
235
|
+
response: PutBlobResponse,
|
|
236
|
+
headers,
|
|
237
|
+
locator: pathname,
|
|
238
|
+
domainErrors: writeErrors,
|
|
239
|
+
onUploadProgress: options.onUploadProgress,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (isBlob(source) && nonImageMediaType(source.type)) {
|
|
243
|
+
return yield* new BlobUploadRejected({
|
|
244
|
+
reason: new ContentTypeNotAllowed({
|
|
245
|
+
message: `optimizeImage requires an image body, but the content type is "${source.type}"`,
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const query = new URLSearchParams({ pathname });
|
|
250
|
+
addImageParams(query, options.optimizeImage);
|
|
251
|
+
return yield* requester.call({
|
|
252
|
+
operation: "BlobStore.putImage",
|
|
253
|
+
method: "POST",
|
|
254
|
+
path: `/put-optimized?${query.toString()}`,
|
|
255
|
+
auth,
|
|
256
|
+
response: PutBlobResponse,
|
|
257
|
+
headers,
|
|
258
|
+
body: source,
|
|
259
|
+
locator: pathname,
|
|
260
|
+
domainErrors: writeErrors,
|
|
261
|
+
onUploadProgress: options.onUploadProgress,
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const head = Effect.fn("BlobStore.head")(function* (blob: BlobLocator) {
|
|
266
|
+
const locator = locatorString(blob);
|
|
267
|
+
const query = new URLSearchParams({ url: locator });
|
|
268
|
+
return yield* requester.call({
|
|
269
|
+
operation: "BlobStore.head",
|
|
270
|
+
method: "GET",
|
|
271
|
+
path: `?${query.toString()}`,
|
|
272
|
+
auth,
|
|
273
|
+
response: HeadBlobResponse,
|
|
274
|
+
locator,
|
|
275
|
+
domainErrors: readErrors,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
/** Where `get` reads a locator, and the pathname it reports, as the SDK resolves them. */
|
|
280
|
+
const resolveBlobUrl = (
|
|
281
|
+
blob: BlobLocator,
|
|
282
|
+
storeId: StoreId,
|
|
283
|
+
access: BlobAccess,
|
|
284
|
+
): Effect.Effect<{ readonly url: URL; readonly pathname: string }> => {
|
|
285
|
+
if (typeof blob === "string" && !isUrl(blob)) {
|
|
286
|
+
return Effect.succeed({
|
|
287
|
+
url: blobUrl(config.blobBaseUrl(storeId, access), blob),
|
|
288
|
+
pathname: blob,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return Effect.suspend(() => {
|
|
292
|
+
let url: URL;
|
|
293
|
+
try {
|
|
294
|
+
url = typeof blob === "string" ? new URL(blob) : new URL(blob.href);
|
|
295
|
+
} catch {
|
|
296
|
+
return invalidInput("Invalid URL: unable to parse the provided URL");
|
|
297
|
+
}
|
|
298
|
+
if (!isAllowedBlobUrl(url, config.apiUrl)) {
|
|
299
|
+
return invalidInput(notBlobStoreUrl);
|
|
300
|
+
}
|
|
301
|
+
return Effect.succeed({ url, pathname: url.pathname.slice(1) });
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const get = Effect.fn("BlobStore.get")(function* (blob: BlobLocator, options: GetOptions) {
|
|
306
|
+
const operation = "BlobStore.get";
|
|
307
|
+
const locator = locatorString(blob);
|
|
308
|
+
if (locator === "") {
|
|
309
|
+
return yield* invalidInput("url or pathname is required");
|
|
310
|
+
}
|
|
311
|
+
const requestAuth = yield* auth;
|
|
312
|
+
if (!BlobRequestAuth.$is("Bearer")(requestAuth)) {
|
|
313
|
+
return yield* invalidInput("Presigned URLs are not supported for the get method");
|
|
314
|
+
}
|
|
315
|
+
// Server auth only carries store ids from `BlobConfig` or a read-write token: both StoreIds.
|
|
316
|
+
const storeId = requestAuth.storeId as StoreId;
|
|
317
|
+
const { url, pathname } = yield* resolveBlobUrl(blob, storeId, options.access);
|
|
318
|
+
|
|
319
|
+
// `useCache: false` bypasses the CDN; the store supports it for private blobs only.
|
|
320
|
+
const fetchUrl = new URL(url.href);
|
|
321
|
+
if (options.useCache === false && options.access === "private") {
|
|
322
|
+
fetchUrl.searchParams.set("cache", "0");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const response = yield* requester.fetch({
|
|
326
|
+
operation,
|
|
327
|
+
url: fetchUrl,
|
|
328
|
+
token: requestAuth.token,
|
|
329
|
+
headers: {
|
|
330
|
+
...(options.ifNoneMatch ? { "if-none-match": options.ifNoneMatch } : {}),
|
|
331
|
+
...options.headers,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
const headers = response.headers;
|
|
335
|
+
|
|
336
|
+
if (response.status === 404) {
|
|
337
|
+
yield* Effect.ignore(response.arrayBuffer);
|
|
338
|
+
return yield* new BlobNotFound({ locator });
|
|
339
|
+
}
|
|
340
|
+
if (response.status !== 304 && (response.status < 200 || response.status >= 300)) {
|
|
341
|
+
yield* Effect.ignore(response.arrayBuffer);
|
|
342
|
+
const now = yield* Clock.currentTimeMillis;
|
|
343
|
+
return yield* vercelApiErrorFromStatus(
|
|
344
|
+
{
|
|
345
|
+
operation,
|
|
346
|
+
status: response.status,
|
|
347
|
+
message: `Failed to fetch blob: ${response.status}`,
|
|
348
|
+
errorCode: Option.none(),
|
|
349
|
+
headers,
|
|
350
|
+
},
|
|
351
|
+
now,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const lastModified = Option.flatMap(
|
|
356
|
+
Option.fromUndefinedOr(header(headers, "last-modified")),
|
|
357
|
+
(value) => DateTime.make(value),
|
|
358
|
+
);
|
|
359
|
+
const uploadedAt = Option.isSome(lastModified) ? lastModified.value : yield* DateTime.now;
|
|
360
|
+
const fetched = new FetchedBlob({
|
|
361
|
+
url,
|
|
362
|
+
downloadUrl: downloadUrl(url),
|
|
363
|
+
pathname,
|
|
364
|
+
contentDisposition: header(headers, "content-disposition") || "",
|
|
365
|
+
cacheControl: header(headers, "cache-control") || "",
|
|
366
|
+
uploadedAt,
|
|
367
|
+
etag: header(headers, "etag") || "",
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (response.status === 304) {
|
|
371
|
+
return GetBlobResult.NotModified({ blob: fetched, headers });
|
|
372
|
+
}
|
|
373
|
+
const contentLength = header(headers, "content-length");
|
|
374
|
+
return GetBlobResult.Found({
|
|
375
|
+
blob: fetched,
|
|
376
|
+
contentType: header(headers, "content-type") || "application/octet-stream",
|
|
377
|
+
size: contentLength ? Number.parseInt(contentLength, 10) : 0,
|
|
378
|
+
headers,
|
|
379
|
+
body: response.stream.pipe(
|
|
380
|
+
Stream.mapError(
|
|
381
|
+
(error) =>
|
|
382
|
+
new VercelApiError({
|
|
383
|
+
operation,
|
|
384
|
+
errorCode: Option.none(),
|
|
385
|
+
reason: new ApiNetworkFailure({ message: error.message, cause: error }),
|
|
386
|
+
}),
|
|
387
|
+
),
|
|
388
|
+
),
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const list = Effect.fn("BlobStore.list")(function* (
|
|
393
|
+
options: ListOptions & { readonly cursor?: string | undefined } = {},
|
|
394
|
+
) {
|
|
395
|
+
const query = new URLSearchParams();
|
|
396
|
+
if (options.limit) query.set("limit", options.limit.toString());
|
|
397
|
+
if (options.prefix) query.set("prefix", options.prefix);
|
|
398
|
+
if (options.cursor) query.set("cursor", options.cursor);
|
|
399
|
+
if (options.mode) query.set("mode", options.mode);
|
|
400
|
+
return yield* requester.call({
|
|
401
|
+
operation: "BlobStore.list",
|
|
402
|
+
method: "GET",
|
|
403
|
+
// An empty query is omitted: the SDK's undici drops a bare `?`, other fetches send it.
|
|
404
|
+
path: query.size === 0 ? "" : `?${query.toString()}`,
|
|
405
|
+
auth,
|
|
406
|
+
response: ListResponse,
|
|
407
|
+
domainErrors: requestErrors,
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const listAll = (options: ListOptions = {}) =>
|
|
412
|
+
Stream.paginate(undefined as string | undefined, (cursor) =>
|
|
413
|
+
Effect.map(list({ ...options, cursor }), (page) => {
|
|
414
|
+
const next = page.hasMore ? page.cursor : Option.none();
|
|
415
|
+
return [page.blobs, next] as const;
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
const deleteUrls = (
|
|
420
|
+
operation: string,
|
|
421
|
+
urls: ReadonlyArray<string>,
|
|
422
|
+
ifMatch: ETag | undefined,
|
|
423
|
+
) =>
|
|
424
|
+
requester.call({
|
|
425
|
+
operation,
|
|
426
|
+
method: "POST",
|
|
427
|
+
path: "/delete",
|
|
428
|
+
auth,
|
|
429
|
+
json: { urls },
|
|
430
|
+
headers: ifMatch ? { "x-if-match": ifMatch } : undefined,
|
|
431
|
+
domainErrors: deleteErrors,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const del = Effect.fn("BlobStore.del")(function* (
|
|
435
|
+
blob: BlobLocator,
|
|
436
|
+
options?: { readonly ifMatch?: ETag | undefined },
|
|
437
|
+
) {
|
|
438
|
+
return yield* deleteUrls("BlobStore.del", [locatorString(blob)], options?.ifMatch);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const delMany = Effect.fn("BlobStore.delMany")(function* (blobs: ReadonlyArray<BlobLocator>) {
|
|
442
|
+
return yield* requester.call({
|
|
443
|
+
operation: "BlobStore.delMany",
|
|
444
|
+
method: "POST",
|
|
445
|
+
path: "/delete",
|
|
446
|
+
auth,
|
|
447
|
+
json: { urls: blobs.map(locatorString) },
|
|
448
|
+
domainErrors: requestErrors,
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const copy = Effect.fn("BlobStore.copy")(function* (
|
|
453
|
+
from: BlobLocator,
|
|
454
|
+
to: BlobPathname,
|
|
455
|
+
options: CreateBlobOptions,
|
|
456
|
+
) {
|
|
457
|
+
const fromUrl = locatorString(from);
|
|
458
|
+
const query = new URLSearchParams({ pathname: to, fromUrl });
|
|
459
|
+
return yield* requester.call({
|
|
460
|
+
operation: "BlobStore.copy",
|
|
461
|
+
method: "PUT",
|
|
462
|
+
path: `?${query.toString()}`,
|
|
463
|
+
auth,
|
|
464
|
+
response: PutBlobResponse,
|
|
465
|
+
headers: copyWriteHeaders(options),
|
|
466
|
+
locator: fromUrl,
|
|
467
|
+
domainErrors: copyErrors,
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const rename = Effect.fn("BlobStore.rename")(function* (
|
|
472
|
+
from: BlobLocator,
|
|
473
|
+
to: BlobPathname,
|
|
474
|
+
options: CreateBlobOptions,
|
|
475
|
+
) {
|
|
476
|
+
const fromUrl = locatorString(from);
|
|
477
|
+
const query = new URLSearchParams({ pathname: to, fromUrl });
|
|
478
|
+
return yield* requester.call({
|
|
479
|
+
operation: "BlobStore.rename",
|
|
480
|
+
method: "POST",
|
|
481
|
+
path: `/rename?${query.toString()}`,
|
|
482
|
+
auth,
|
|
483
|
+
response: PutBlobResponse,
|
|
484
|
+
headers: copyWriteHeaders(options),
|
|
485
|
+
locator: fromUrl,
|
|
486
|
+
domainErrors: copyErrors,
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const createFolder = Effect.fn("BlobStore.createFolder")(function* (
|
|
491
|
+
pathname: BlobPathname,
|
|
492
|
+
options?: { readonly access?: BlobAccess | undefined },
|
|
493
|
+
) {
|
|
494
|
+
const folder = pathname.endsWith("/") ? pathname : `${pathname}/`;
|
|
495
|
+
const query = new URLSearchParams({ pathname: folder });
|
|
496
|
+
return yield* requester.call({
|
|
497
|
+
operation: "BlobStore.createFolder",
|
|
498
|
+
method: "PUT",
|
|
499
|
+
path: `/?${query.toString()}`,
|
|
500
|
+
auth,
|
|
501
|
+
response: CreateFolderResponse,
|
|
502
|
+
headers: {
|
|
503
|
+
"x-vercel-blob-access": options?.access ?? "public",
|
|
504
|
+
"x-add-random-suffix": "0",
|
|
505
|
+
},
|
|
506
|
+
locator: folder,
|
|
507
|
+
domainErrors: writeErrors,
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
return BlobStore.of({
|
|
512
|
+
put,
|
|
513
|
+
putImage,
|
|
514
|
+
head,
|
|
515
|
+
get,
|
|
516
|
+
list,
|
|
517
|
+
listAll,
|
|
518
|
+
del,
|
|
519
|
+
delMany,
|
|
520
|
+
copy,
|
|
521
|
+
rename,
|
|
522
|
+
createFolder,
|
|
523
|
+
createMultipartUpload: multipart.createMultipartUpload,
|
|
524
|
+
uploadPart: multipart.uploadPart,
|
|
525
|
+
completeMultipartUpload: multipart.completeMultipartUpload,
|
|
526
|
+
});
|
|
527
|
+
}),
|
|
528
|
+
);
|