@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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -2
  3. package/dist/client.d.ts +8 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +8 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/contracts/blob-client.d.ts +67 -0
  8. package/dist/contracts/blob-client.d.ts.map +1 -0
  9. package/dist/contracts/blob-client.js +11 -0
  10. package/dist/contracts/blob-client.js.map +1 -0
  11. package/dist/contracts/blob-config.d.ts +114 -0
  12. package/dist/contracts/blob-config.d.ts.map +1 -0
  13. package/dist/contracts/blob-config.js +7 -0
  14. package/dist/contracts/blob-config.js.map +1 -0
  15. package/dist/contracts/blob-domain.d.ts +336 -0
  16. package/dist/contracts/blob-domain.d.ts.map +1 -0
  17. package/dist/contracts/blob-domain.js +152 -0
  18. package/dist/contracts/blob-domain.js.map +1 -0
  19. package/dist/contracts/blob-errors.d.ts +125 -0
  20. package/dist/contracts/blob-errors.d.ts.map +1 -0
  21. package/dist/contracts/blob-errors.js +114 -0
  22. package/dist/contracts/blob-errors.js.map +1 -0
  23. package/dist/contracts/blob-signed-urls.d.ts +83 -0
  24. package/dist/contracts/blob-signed-urls.d.ts.map +1 -0
  25. package/dist/contracts/blob-signed-urls.js +10 -0
  26. package/dist/contracts/blob-signed-urls.js.map +1 -0
  27. package/dist/contracts/blob-store.d.ts +153 -0
  28. package/dist/contracts/blob-store.d.ts.map +1 -0
  29. package/dist/contracts/blob-store.js +15 -0
  30. package/dist/contracts/blob-store.js.map +1 -0
  31. package/dist/contracts/blob-uploads.d.ts +101 -0
  32. package/dist/contracts/blob-uploads.d.ts.map +1 -0
  33. package/dist/contracts/blob-uploads.js +17 -0
  34. package/dist/contracts/blob-uploads.js.map +1 -0
  35. package/dist/implementation/blob-client-live.d.ts +37 -0
  36. package/dist/implementation/blob-client-live.d.ts.map +1 -0
  37. package/dist/implementation/blob-client-live.js +213 -0
  38. package/dist/implementation/blob-client-live.js.map +1 -0
  39. package/dist/implementation/blob-config-live.d.ts +49 -0
  40. package/dist/implementation/blob-config-live.d.ts.map +1 -0
  41. package/dist/implementation/blob-config-live.js +82 -0
  42. package/dist/implementation/blob-config-live.js.map +1 -0
  43. package/dist/implementation/blob-live.d.ts +16 -0
  44. package/dist/implementation/blob-live.d.ts.map +1 -0
  45. package/dist/implementation/blob-live.js +16 -0
  46. package/dist/implementation/blob-live.js.map +1 -0
  47. package/dist/implementation/blob-signed-urls-live.d.ts +12 -0
  48. package/dist/implementation/blob-signed-urls-live.d.ts.map +1 -0
  49. package/dist/implementation/blob-signed-urls-live.js +92 -0
  50. package/dist/implementation/blob-signed-urls-live.js.map +1 -0
  51. package/dist/implementation/blob-store-live.d.ts +18 -0
  52. package/dist/implementation/blob-store-live.d.ts.map +1 -0
  53. package/dist/implementation/blob-store-live.js +364 -0
  54. package/dist/implementation/blob-store-live.js.map +1 -0
  55. package/dist/implementation/blob-uploads-live.d.ts +11 -0
  56. package/dist/implementation/blob-uploads-live.d.ts.map +1 -0
  57. package/dist/implementation/blob-uploads-live.js +218 -0
  58. package/dist/implementation/blob-uploads-live.js.map +1 -0
  59. package/dist/index.d.ts +15 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +15 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/internal/blob-errors.d.ts +79 -0
  64. package/dist/internal/blob-errors.d.ts.map +1 -0
  65. package/dist/internal/blob-errors.js +196 -0
  66. package/dist/internal/blob-errors.js.map +1 -0
  67. package/dist/internal/blob-request.d.ts +255 -0
  68. package/dist/internal/blob-request.d.ts.map +1 -0
  69. package/dist/internal/blob-request.js +291 -0
  70. package/dist/internal/blob-request.js.map +1 -0
  71. package/dist/internal/callback.d.ts +70 -0
  72. package/dist/internal/callback.d.ts.map +1 -0
  73. package/dist/internal/callback.js +141 -0
  74. package/dist/internal/callback.js.map +1 -0
  75. package/dist/internal/client-token.d.ts +65 -0
  76. package/dist/internal/client-token.d.ts.map +1 -0
  77. package/dist/internal/client-token.js +80 -0
  78. package/dist/internal/client-token.js.map +1 -0
  79. package/dist/internal/env.d.ts +34 -0
  80. package/dist/internal/env.d.ts.map +1 -0
  81. package/dist/internal/env.js +61 -0
  82. package/dist/internal/env.js.map +1 -0
  83. package/dist/internal/multipart.d.ts +110 -0
  84. package/dist/internal/multipart.d.ts.map +1 -0
  85. package/dist/internal/multipart.js +290 -0
  86. package/dist/internal/multipart.js.map +1 -0
  87. package/dist/internal/presign.d.ts +123 -0
  88. package/dist/internal/presign.d.ts.map +1 -0
  89. package/dist/internal/presign.js +345 -0
  90. package/dist/internal/presign.js.map +1 -0
  91. package/dist/internal/store-id.d.ts +38 -0
  92. package/dist/internal/store-id.d.ts.map +1 -0
  93. package/dist/internal/store-id.js +54 -0
  94. package/dist/internal/store-id.js.map +1 -0
  95. package/dist/internal/urls.d.ts +26 -0
  96. package/dist/internal/urls.d.ts.map +1 -0
  97. package/dist/internal/urls.js +33 -0
  98. package/dist/internal/urls.js.map +1 -0
  99. package/dist/internal/wire.d.ts +55 -0
  100. package/dist/internal/wire.d.ts.map +1 -0
  101. package/dist/internal/wire.js +64 -0
  102. package/dist/internal/wire.js.map +1 -0
  103. package/dist/internal/write-headers.d.ts +56 -0
  104. package/dist/internal/write-headers.d.ts.map +1 -0
  105. package/dist/internal/write-headers.js +82 -0
  106. package/dist/internal/write-headers.js.map +1 -0
  107. package/dist/testing/index.d.ts +2 -0
  108. package/dist/testing/index.d.ts.map +1 -0
  109. package/dist/testing/index.js +2 -0
  110. package/dist/testing/index.js.map +1 -0
  111. package/dist/testing/test-blob-store.d.ts +134 -0
  112. package/dist/testing/test-blob-store.d.ts.map +1 -0
  113. package/dist/testing/test-blob-store.js +718 -0
  114. package/dist/testing/test-blob-store.js.map +1 -0
  115. package/package.json +65 -3
  116. package/src/client.ts +11 -0
  117. package/src/contracts/blob-client.ts +121 -0
  118. package/src/contracts/blob-config.ts +64 -0
  119. package/src/contracts/blob-domain.ts +304 -0
  120. package/src/contracts/blob-errors.ts +161 -0
  121. package/src/contracts/blob-signed-urls.ts +112 -0
  122. package/src/contracts/blob-store.ts +258 -0
  123. package/src/contracts/blob-uploads.ts +151 -0
  124. package/src/implementation/blob-client-live.ts +387 -0
  125. package/src/implementation/blob-config-live.ts +145 -0
  126. package/src/implementation/blob-live.ts +30 -0
  127. package/src/implementation/blob-signed-urls-live.ts +121 -0
  128. package/src/implementation/blob-store-live.ts +528 -0
  129. package/src/implementation/blob-uploads-live.ts +326 -0
  130. package/src/index.ts +18 -0
  131. package/src/internal/blob-errors.ts +325 -0
  132. package/src/internal/blob-request.ts +617 -0
  133. package/src/internal/callback.ts +200 -0
  134. package/src/internal/client-token.ts +132 -0
  135. package/src/internal/env.ts +128 -0
  136. package/src/internal/multipart.ts +526 -0
  137. package/src/internal/presign.ts +477 -0
  138. package/src/internal/store-id.ts +69 -0
  139. package/src/internal/urls.ts +42 -0
  140. package/src/internal/wire.ts +80 -0
  141. package/src/internal/write-headers.ts +118 -0
  142. package/src/testing/index.ts +1 -0
  143. package/src/testing/test-blob-store.ts +1136 -0
@@ -0,0 +1,161 @@
1
+ import { Schema } from "effect";
2
+
3
+ /** The blob does not exist (`not_found`). */
4
+ export class BlobNotFound extends Schema.TaggedError<BlobNotFound>()("BlobNotFound", {
5
+ /** The pathname or URL that was requested. */
6
+ locator: Schema.String,
7
+ }) {}
8
+
9
+ /** The store addressed by the credentials does not exist (`store_not_found`). */
10
+ export class BlobStoreNotFound extends Schema.TaggedError<BlobStoreNotFound>()(
11
+ "BlobStoreNotFound",
12
+ {},
13
+ ) {}
14
+
15
+ /** The store has been suspended, for example for exceeding the Hobby quota (`store_suspended`). */
16
+ export class BlobStoreSuspended extends Schema.TaggedError<BlobStoreSuspended>()(
17
+ "BlobStoreSuspended",
18
+ {},
19
+ ) {}
20
+
21
+ /** The store cannot serve any request. */
22
+ export class BlobStoreUnavailable extends Schema.TaggedError<BlobStoreUnavailable>()(
23
+ "BlobStoreUnavailable",
24
+ {
25
+ reason: Schema.Union([BlobStoreNotFound, BlobStoreSuspended]),
26
+ },
27
+ ) {}
28
+
29
+ /** A conditional write or delete found a different ETag than `ifMatch` (`precondition_failed`). */
30
+ export class BlobPreconditionFailed extends Schema.TaggedError<BlobPreconditionFailed>()(
31
+ "BlobPreconditionFailed",
32
+ {},
33
+ ) {}
34
+
35
+ /** The upload's media type is not among the token's `allowedContentTypes`. */
36
+ export class ContentTypeNotAllowed extends Schema.TaggedError<ContentTypeNotAllowed>()(
37
+ "ContentTypeNotAllowed",
38
+ {
39
+ message: Schema.String,
40
+ },
41
+ ) {}
42
+
43
+ /** The upload is larger than `maximumSizeInBytes`, checked locally or by the store. */
44
+ export class FileTooLarge extends Schema.TaggedError<FileTooLarge>()("FileTooLarge", {
45
+ message: Schema.String,
46
+ }) {}
47
+
48
+ /** The upload's pathname differs from the one the client token was issued for. */
49
+ export class PathnameMismatch extends Schema.TaggedError<PathnameMismatch>()("PathnameMismatch", {
50
+ message: Schema.String,
51
+ }) {}
52
+
53
+ /** The client token's `validUntil` has passed. */
54
+ export class ClientTokenExpired extends Schema.TaggedError<ClientTokenExpired>()(
55
+ "ClientTokenExpired",
56
+ {},
57
+ ) {}
58
+
59
+ /** The store refused an upload because it breaks a constraint of its token. */
60
+ export class BlobUploadRejected extends Schema.TaggedError<BlobUploadRejected>()(
61
+ "BlobUploadRejected",
62
+ {
63
+ reason: Schema.Union([
64
+ ContentTypeNotAllowed,
65
+ FileTooLarge,
66
+ PathnameMismatch,
67
+ ClientTokenExpired,
68
+ ]),
69
+ },
70
+ ) {}
71
+
72
+ /**
73
+ * The operation needs a read-write or OIDC token and was called with a client token
74
+ * (`client_token_not_allowed`), such as `rename`.
75
+ */
76
+ export class BlobClientTokenNotAllowed extends Schema.TaggedError<BlobClientTokenNotAllowed>()(
77
+ "BlobClientTokenNotAllowed",
78
+ {
79
+ message: Schema.String,
80
+ },
81
+ ) {}
82
+
83
+ /**
84
+ * The project enables OIDC for Blob but not for the environment the token was issued in
85
+ * (`oidc_environment_not_allowed`). Enable it in the store's settings or use a read-write token.
86
+ */
87
+ export class BlobOidcEnvironmentNotAllowed extends Schema.TaggedError<BlobOidcEnvironmentNotAllowed>()(
88
+ "BlobOidcEnvironmentNotAllowed",
89
+ {
90
+ message: Schema.String,
91
+ },
92
+ ) {}
93
+
94
+ /**
95
+ * The operation signs with the store's read-write token, and none is configured. OIDC is not
96
+ * enough for client tokens and their callbacks; set `BLOB_READ_WRITE_TOKEN`.
97
+ */
98
+ export class BlobReadWriteTokenRequired extends Schema.TaggedError<BlobReadWriteTokenRequired>()(
99
+ "BlobReadWriteTokenRequired",
100
+ {
101
+ operation: Schema.String,
102
+ },
103
+ ) {}
104
+
105
+ /** A presigned URL could not be signed: the token's scope or the options do not allow it. */
106
+ export class BlobPresignFailed extends Schema.TaggedError<BlobPresignFailed>()(
107
+ "BlobPresignFailed",
108
+ {
109
+ message: Schema.String,
110
+ },
111
+ ) {}
112
+
113
+ /** The `x-vercel-signature` header is absent. */
114
+ export class SignatureMissing extends Schema.TaggedError<SignatureMissing>()(
115
+ "SignatureMissing",
116
+ {},
117
+ ) {}
118
+
119
+ /** The `x-vercel-signature` header does not match the body. */
120
+ export class SignatureInvalid extends Schema.TaggedError<SignatureInvalid>()(
121
+ "SignatureInvalid",
122
+ {},
123
+ ) {}
124
+
125
+ /** The request body is not a known upload event, or not JSON. */
126
+ export class EventMalformed extends Schema.TaggedError<EventMalformed>()("EventMalformed", {
127
+ message: Schema.String,
128
+ }) {}
129
+
130
+ /** The presigned flow has no webhook public key: set `BLOB_WEBHOOK_PUBLIC_KEY`. */
131
+ export class WebhookPublicKeyMissing extends Schema.TaggedError<WebhookPublicKeyMissing>()(
132
+ "WebhookPublicKeyMissing",
133
+ {},
134
+ ) {}
135
+
136
+ /**
137
+ * An upload route received a request it must refuse. Respond with a 4xx: the blob store retries an
138
+ * upload-completed callback that does not get a 200.
139
+ */
140
+ export class BlobUploadEventRejected extends Schema.TaggedError<BlobUploadEventRejected>()(
141
+ "BlobUploadEventRejected",
142
+ {
143
+ reason: Schema.Union([
144
+ SignatureMissing,
145
+ SignatureInvalid,
146
+ EventMalformed,
147
+ WebhookPublicKeyMissing,
148
+ ]),
149
+ },
150
+ ) {}
151
+
152
+ /** The browser could not get a client token or presigned URL from the upload route. */
153
+ export class BlobUploadAuthorizationFailed extends Schema.TaggedError<BlobUploadAuthorizationFailed>()(
154
+ "BlobUploadAuthorizationFailed",
155
+ {
156
+ /** The route's status code; none when the request failed before a response. */
157
+ status: Schema.Option(Schema.Int),
158
+ message: Schema.String,
159
+ cause: Schema.optional(Schema.Defect()),
160
+ },
161
+ ) {}
@@ -0,0 +1,112 @@
1
+ import type { Array, DateTime, Effect } from "effect";
2
+
3
+ import { Context } from "effect";
4
+
5
+ import type {
6
+ BlobAccess,
7
+ DelegationOperation,
8
+ ETag,
9
+ PresignedUrlPayload,
10
+ SignedToken,
11
+ } from "./blob-domain.ts";
12
+ import type { BlobPresignFailed } from "./blob-errors.ts";
13
+ import type { BlobRequestError } from "./blob-store.ts";
14
+
15
+ /** Scope of a signed token. Every field narrows what presigned URLs signed from it may do. */
16
+ export interface IssueSignedTokenOptions {
17
+ /** Pathname the token covers; `*` or omitted covers the whole store. */
18
+ readonly pathname?: string | undefined;
19
+
20
+ /** Operations the token delegates; the store defaults to `get`. Duplicates are removed. */
21
+ readonly operations?: Array.NonEmptyReadonlyArray<DelegationOperation> | undefined;
22
+
23
+ /** Expiry, in the future and at most 7 days away; the store defaults to one hour. */
24
+ readonly validUntil?: DateTime.Utc | undefined;
25
+
26
+ /** Largest upload a presigned `put` may send. */
27
+ readonly maximumSizeInBytes?: number | undefined;
28
+
29
+ /** Media types a presigned `put` may upload; wildcards such as `image/*` are allowed. */
30
+ readonly allowedContentTypes?: ReadonlyArray<string> | undefined;
31
+ }
32
+
33
+ /** Callback the store calls after a presigned upload completes. */
34
+ export interface PresignUploadCompleted {
35
+ /** Absolute URL of the route that handles `blob.upload-completed`. */
36
+ readonly callbackUrl: string;
37
+
38
+ /** Data passed back to the callback, such as the uploading user's id. */
39
+ readonly tokenPayload?: string | undefined;
40
+ }
41
+
42
+ /** What one presigned URL authorizes. Constraints may only narrow the signed token's. */
43
+ export type PresignOptions =
44
+ | {
45
+ readonly operation: "get";
46
+ readonly pathname: string;
47
+ /** Earlier expiry than the token's. */
48
+ readonly validUntil?: DateTime.Utc | undefined;
49
+ /** Add `cache=0` to read a private blob from origin storage; not signed. */
50
+ readonly useCache?: boolean | undefined;
51
+ }
52
+ | {
53
+ readonly operation: "head";
54
+ readonly pathname: string;
55
+ readonly validUntil?: DateTime.Utc | undefined;
56
+ }
57
+ | {
58
+ readonly operation: "put";
59
+ readonly pathname: string;
60
+ readonly validUntil?: DateTime.Utc | undefined;
61
+ readonly allowedContentTypes?: ReadonlyArray<string> | undefined;
62
+ readonly maximumSizeInBytes?: number | undefined;
63
+ readonly onUploadCompleted?: PresignUploadCompleted | undefined;
64
+ readonly allowOverwrite?: boolean | undefined;
65
+ readonly addRandomSuffix?: boolean | undefined;
66
+ /** In seconds, at most one year. */
67
+ readonly cacheControlMaxAge?: number | undefined;
68
+ readonly ifMatch?: ETag | undefined;
69
+ }
70
+ | {
71
+ readonly operation: "delete";
72
+ readonly pathname: string;
73
+ readonly validUntil?: DateTime.Utc | undefined;
74
+ readonly ifMatch?: ETag | undefined;
75
+ };
76
+
77
+ /** The secret and public halves of a signed token, as needed to presign. */
78
+ export type SigningToken = Pick<SignedToken, "delegationToken" | "clientSigningToken">;
79
+
80
+ /**
81
+ * Signed URLs: delegate one operation on one pathname to a holder of a URL, without credentials.
82
+ *
83
+ * Issue a {@link SignedToken} once (one API call) and cache it until shortly before it expires;
84
+ * presigning is local HMAC-SHA256 signing.
85
+ */
86
+ export class BlobSignedUrls extends Context.Service<
87
+ BlobSignedUrls,
88
+ {
89
+ /** Ask the store for a signed token (`POST /signed-token`). */
90
+ readonly issueSignedToken: (
91
+ options?: IssueSignedTokenOptions,
92
+ ) => Effect.Effect<SignedToken, BlobRequestError>;
93
+
94
+ /**
95
+ * Sign one request. Fails with `BlobPresignFailed` when the token has expired, does not delegate
96
+ * the operation, covers another pathname, or allows less than the options ask for.
97
+ */
98
+ readonly presign: (
99
+ token: SigningToken,
100
+ options: PresignOptions,
101
+ ) => Effect.Effect<PresignedUrlPayload, BlobPresignFailed>;
102
+
103
+ /**
104
+ * Sign one request and build its URL: the blob URL for `get` and `head`, the Blob API URL for
105
+ * `put` and `delete`.
106
+ */
107
+ readonly presignUrl: (
108
+ token: SigningToken,
109
+ options: PresignOptions & { readonly access: BlobAccess },
110
+ ) => Effect.Effect<URL, BlobPresignFailed>;
111
+ }
112
+ >()("@effect-vercel/blob/BlobSignedUrls") {}
@@ -0,0 +1,258 @@
1
+ import type { OidcTokenUnavailable, VercelApiError } from "@panorama-industries/effect-vercel-core";
2
+ import type { Array, Duration, Effect, Stream } from "effect";
3
+
4
+ import { Context, Schema } from "effect";
5
+
6
+ import type {
7
+ BlobAccess,
8
+ BlobBody,
9
+ BlobLocator,
10
+ BlobMetadata,
11
+ BlobPathname,
12
+ CreateBlobOptions,
13
+ CreateFolderResult,
14
+ ETag,
15
+ GetBlobResult,
16
+ ListedBlob,
17
+ ListPage,
18
+ MultipartUpload,
19
+ OnUploadProgress,
20
+ OverwriteOptions,
21
+ PartNumber,
22
+ PutBlobResult,
23
+ UploadedPart,
24
+ } from "./blob-domain.ts";
25
+ import type {
26
+ BlobNotFound,
27
+ BlobOidcEnvironmentNotAllowed,
28
+ BlobPreconditionFailed,
29
+ BlobStoreUnavailable,
30
+ BlobUploadRejected,
31
+ } from "./blob-errors.ts";
32
+
33
+ /** Failures every Blob API call can report. */
34
+ export type BlobRequestError =
35
+ | VercelApiError
36
+ | BlobStoreUnavailable
37
+ | BlobOidcEnvironmentNotAllowed
38
+ | OidcTokenUnavailable;
39
+
40
+ /** Failures of creating or replacing a blob. */
41
+ export type BlobWriteError = BlobRequestError | BlobPreconditionFailed | BlobUploadRejected;
42
+
43
+ /** Options for {@link BlobStore} `put`. */
44
+ export type PutOptions = CreateBlobOptions & {
45
+ /**
46
+ * Upload in 8 MiB parts, eight at a time, instead of one request. Use it for large bodies; parts
47
+ * are produced from the body as it streams, so memory stays bounded.
48
+ */
49
+ readonly multipart?: boolean | undefined;
50
+
51
+ /**
52
+ * Refuse a multipart upload whose known size exceeds this many bytes before sending anything.
53
+ * Streams of unknown size are not checked.
54
+ */
55
+ readonly maximumSizeInBytes?: number | undefined;
56
+
57
+ readonly onUploadProgress?: OnUploadProgress | undefined;
58
+ };
59
+
60
+ /** Width of an optimized image in pixels: an integer from 1 to 8192. */
61
+ export const ImageWidth = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 8192 })).pipe(
62
+ Schema.brand("ImageWidth"),
63
+ );
64
+
65
+ /** Width of an optimized image in pixels. */
66
+ export type ImageWidth = typeof ImageWidth.Type;
67
+
68
+ /** Quality of an optimized image: an integer from 1 to 100. */
69
+ export const ImageQuality = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 100 })).pipe(
70
+ Schema.brand("ImageQuality"),
71
+ );
72
+
73
+ /** Quality of an optimized image: an integer from 1 to 100. */
74
+ export type ImageQuality = typeof ImageQuality.Type;
75
+
76
+ /** Output format of an optimized image. */
77
+ export const ImageFormat = Schema.Literals(["jpeg", "png", "webp", "avif"]);
78
+
79
+ /** Output format of an optimized image. */
80
+ export type ImageFormat = typeof ImageFormat.Type;
81
+
82
+ /** How Vercel Image Optimization transforms an image before it is stored. */
83
+ export interface ImageOptimization {
84
+ readonly width: ImageWidth;
85
+
86
+ /** Defaults to 75. */
87
+ readonly quality?: ImageQuality | undefined;
88
+
89
+ /** Defaults to the source format. */
90
+ readonly format?: ImageFormat | undefined;
91
+ }
92
+
93
+ /** Options for {@link BlobStore} `putImage`; the stored media type comes from the optimizer. */
94
+ export type PutImageOptions = OverwriteOptions & {
95
+ readonly access: BlobAccess;
96
+ readonly addRandomSuffix?: boolean | undefined;
97
+ readonly cacheControlMaxAge?: Duration.Input | undefined;
98
+ readonly optimizeImage: ImageOptimization;
99
+ readonly onUploadProgress?: OnUploadProgress | undefined;
100
+ };
101
+
102
+ /** Options for {@link BlobStore} `get`. */
103
+ export interface GetOptions {
104
+ readonly access: BlobAccess;
105
+
106
+ /**
107
+ * When false, read a private blob from origin storage instead of the CDN cache: always the latest
108
+ * content, but slower. Ignored for public blobs.
109
+ */
110
+ readonly useCache?: boolean | undefined;
111
+
112
+ /** Return `NotModified` instead of the content when the blob still has this ETag. */
113
+ readonly ifNoneMatch?: string | undefined;
114
+
115
+ /** Extra request headers; they override the defaults, including `authorization`. */
116
+ readonly headers?: Readonly<Record<string, string>> | undefined;
117
+ }
118
+
119
+ /** Options for {@link BlobStore} `list` and `listAll`. */
120
+ export interface ListOptions {
121
+ /** Entries per page; the store's default and maximum is 1000. */
122
+ readonly limit?: number | undefined;
123
+
124
+ /** Only blobs whose pathname starts with this. */
125
+ readonly prefix?: string | undefined;
126
+
127
+ /**
128
+ * `folded` returns the folders directly under `prefix` in `folders` and only the blobs at that
129
+ * level in `blobs`.
130
+ */
131
+ readonly mode?: "expanded" | "folded" | undefined;
132
+ }
133
+
134
+ /** Options for {@link BlobStore} `createMultipartUpload`. */
135
+ export type CreateMultipartUploadOptions = CreateBlobOptions;
136
+
137
+ /**
138
+ * The Vercel Blob server API: upload, read, list, copy, rename and delete blobs in one store.
139
+ *
140
+ * Credentials and endpoints come from `BlobConfig`.
141
+ */
142
+ export class BlobStore extends Context.Service<
143
+ BlobStore,
144
+ {
145
+ /**
146
+ * Store a blob at `pathname`. The store refuses an existing pathname unless `allowOverwrite`
147
+ * or `ifMatch` is set; `ifMatch` fails with `BlobPreconditionFailed` when the blob has a
148
+ * different ETag or does not exist. `access` must be the store's.
149
+ */
150
+ readonly put: <E = never, R = never>(
151
+ pathname: BlobPathname,
152
+ body: BlobBody<E, R>,
153
+ options: PutOptions,
154
+ ) => Effect.Effect<PutBlobResult, BlobWriteError | E, R>;
155
+
156
+ /**
157
+ * Optimize an image with Vercel Image Optimization and store only the result. The source is
158
+ * the image content, or a `URL` the store fetches itself. Requires OIDC credentials. A source
159
+ * whose known media type is not an image fails with `BlobUploadRejected` before any request.
160
+ */
161
+ readonly putImage: <E = never, R = never>(
162
+ pathname: BlobPathname,
163
+ source: BlobBody<E, R> | URL,
164
+ options: PutImageOptions,
165
+ ) => Effect.Effect<PutBlobResult, BlobWriteError | E, R>;
166
+
167
+ /** Read a blob's metadata without its content. */
168
+ readonly head: (
169
+ blob: BlobLocator,
170
+ ) => Effect.Effect<BlobMetadata, BlobRequestError | BlobNotFound>;
171
+
172
+ /**
173
+ * Read a blob's content from its store's host. A pathname is resolved against the configured
174
+ * store; a URL must point at a Blob store (or the configured API origin, for emulators).
175
+ */
176
+ readonly get: (
177
+ blob: BlobLocator,
178
+ options: GetOptions,
179
+ ) => Effect.Effect<GetBlobResult, BlobRequestError | BlobNotFound>;
180
+
181
+ /** Read one page of blobs. */
182
+ readonly list: (
183
+ options?: ListOptions & { readonly cursor?: string | undefined },
184
+ ) => Effect.Effect<ListPage, BlobRequestError>;
185
+
186
+ /** Every blob matching `options`, fetching pages as the stream is read. */
187
+ readonly listAll: (options?: ListOptions) => Stream.Stream<ListedBlob, BlobRequestError>;
188
+
189
+ /**
190
+ * Delete a blob. Deleting a missing blob succeeds; with `ifMatch`, a different ETag or a
191
+ * missing blob fails with `BlobPreconditionFailed`. CDN caches may serve the blob for up to a
192
+ * minute afterwards.
193
+ */
194
+ readonly del: (
195
+ blob: BlobLocator,
196
+ options?: { readonly ifMatch?: ETag | undefined },
197
+ ) => Effect.Effect<void, BlobRequestError | BlobPreconditionFailed>;
198
+
199
+ /** Delete several blobs in one request; each counts as one operation for rate limits. */
200
+ readonly delMany: (
201
+ blobs: Array.NonEmptyReadonlyArray<BlobLocator>,
202
+ ) => Effect.Effect<void, BlobRequestError>;
203
+
204
+ /**
205
+ * Copy a blob to `to`. The copy does not inherit the source's media type or cache lifetime;
206
+ * pass them again. `ifMatch` is checked against the source. The store replaces a blob at `to`
207
+ * even when `allowOverwrite` is false. A missing source fails with `BlobNotFound`.
208
+ */
209
+ readonly copy: (
210
+ from: BlobLocator,
211
+ to: BlobPathname,
212
+ options: CreateBlobOptions,
213
+ ) => Effect.Effect<PutBlobResult, BlobWriteError | BlobNotFound>;
214
+
215
+ /**
216
+ * Move a blob to `to`: copied, then the source deleted. Unlike `copy`, an existing `to` is
217
+ * refused unless `allowOverwrite` is set, so if the delete fails the blob exists at both
218
+ * pathnames; retry with `allowOverwrite: true`. A missing source fails with `BlobNotFound`.
219
+ */
220
+ readonly rename: (
221
+ from: BlobLocator,
222
+ to: BlobPathname,
223
+ options: CreateBlobOptions,
224
+ ) => Effect.Effect<PutBlobResult, BlobWriteError | BlobNotFound>;
225
+
226
+ /**
227
+ * Create an empty folder marker; a trailing `/` is added when missing. An existing folder is
228
+ * refused. `access` defaults to `public`, which a private store refuses.
229
+ */
230
+ readonly createFolder: (
231
+ pathname: BlobPathname,
232
+ options?: { readonly access?: BlobAccess | undefined },
233
+ ) => Effect.Effect<CreateFolderResult, BlobWriteError>;
234
+
235
+ /**
236
+ * Start a multipart upload to control part sizes and concurrency yourself. Parts must be at
237
+ * least 5 MB except the last, and should all have the same size. `addRandomSuffix` defaults
238
+ * to true here.
239
+ */
240
+ readonly createMultipartUpload: (
241
+ pathname: BlobPathname,
242
+ options: CreateMultipartUploadOptions,
243
+ ) => Effect.Effect<MultipartUpload, BlobWriteError>;
244
+
245
+ /** Upload one part of a multipart upload. */
246
+ readonly uploadPart: <E = never, R = never>(
247
+ upload: MultipartUpload,
248
+ partNumber: PartNumber,
249
+ body: BlobBody<E, R>,
250
+ ) => Effect.Effect<UploadedPart, BlobWriteError | E, R>;
251
+
252
+ /** Assemble the uploaded parts into the blob. */
253
+ readonly completeMultipartUpload: (
254
+ upload: MultipartUpload,
255
+ parts: Array.NonEmptyReadonlyArray<UploadedPart>,
256
+ ) => Effect.Effect<PutBlobResult, BlobWriteError>;
257
+ }
258
+ >()("@effect-vercel/blob/BlobStore") {}
@@ -0,0 +1,151 @@
1
+ import type { Effect } from "effect";
2
+ import type { HttpServerRequest, HttpServerResponse } from "effect/http";
3
+
4
+ import { Context } from "effect";
5
+
6
+ import type {
7
+ ClientToken,
8
+ ClientTokenConstraints,
9
+ UploadCompleted,
10
+ UploadRequest,
11
+ } from "./blob-domain.ts";
12
+ import type {
13
+ BlobPresignFailed,
14
+ BlobReadWriteTokenRequired,
15
+ BlobUploadEventRejected,
16
+ } from "./blob-errors.ts";
17
+ import type { PresignOptions, SigningToken } from "./blob-signed-urls.ts";
18
+
19
+ /** Callback the store calls after a browser upload completes. */
20
+ export interface UploadCompletedCallback {
21
+ /** Absolute URL of the route that handles `blob.upload-completed`. */
22
+ readonly callbackUrl: string;
23
+
24
+ /** Data passed back to the callback, such as the uploading user's id. */
25
+ readonly tokenPayload?: string | undefined;
26
+ }
27
+
28
+ /** Everything a client token encodes. */
29
+ export type ClientTokenOptions = ClientTokenConstraints & {
30
+ /** The only pathname the token may upload to. */
31
+ readonly pathname: string;
32
+
33
+ readonly onUploadCompleted?: UploadCompletedCallback | undefined;
34
+ };
35
+
36
+ /** What an upload route allows for one browser upload; returned by `onBeforeGenerateToken`. */
37
+ export type UploadAuthorization = ClientTokenConstraints & {
38
+ /** Passed to `onUploadCompleted`; defaults to the browser's `clientPayload`. */
39
+ readonly tokenPayload?: string | undefined;
40
+
41
+ /**
42
+ * Where the upload-completed callback goes. Derived from the environment when omitted:
43
+ * `VERCEL_BLOB_CALLBACK_URL`, else the preview or production URL on Vercel, plus this route's
44
+ * path. Never taken from the browser.
45
+ */
46
+ readonly callbackUrl?: string | undefined;
47
+ };
48
+
49
+ /** Hooks of a client-token upload route. */
50
+ export interface UploadHandlerOptions<E, R, E2, R2> {
51
+ /**
52
+ * Authenticate and authorize the user, then constrain the upload. Fail to refuse it; the error
53
+ * leaves the handler so the route can answer 401 or 403.
54
+ */
55
+ readonly onBeforeGenerateToken: (
56
+ request: UploadRequest,
57
+ ) => Effect.Effect<UploadAuthorization, E, R>;
58
+
59
+ /**
60
+ * Runs when the store reports the upload finished, after the callback's signature is verified.
61
+ * Without it no callback is requested. Fail to make the store retry the callback.
62
+ */
63
+ readonly onUploadCompleted?:
64
+ | ((event: UploadCompleted) => Effect.Effect<void, E2, R2>)
65
+ | undefined;
66
+ }
67
+
68
+ /** What a presigned upload route allows for one browser upload; returned by `getSignedToken`. */
69
+ export interface PresignedUploadAuthorization {
70
+ /** Token to sign the upload URL with, usually cached by the route. */
71
+ readonly token: SigningToken;
72
+
73
+ /** Constraints signed into the URL; they may only narrow the token's. */
74
+ readonly urlOptions?:
75
+ | (Omit<
76
+ Extract<PresignOptions, { readonly operation: "put" }>,
77
+ "operation" | "pathname" | "onUploadCompleted"
78
+ > & {
79
+ /** As {@link UploadAuthorization.callbackUrl}. */
80
+ readonly callbackUrl?: string | undefined;
81
+ /** Passed to `onUploadCompleted`; defaults to the browser's `clientPayload`. */
82
+ readonly tokenPayload?: string | undefined;
83
+ })
84
+ | undefined;
85
+ }
86
+
87
+ /** Hooks of a presigned upload route. */
88
+ export interface PresignedUploadHandlerOptions<E, R, E2, R2> {
89
+ /** Authenticate and authorize the user, then choose the token and constraints. */
90
+ readonly getSignedToken: (
91
+ request: UploadRequest,
92
+ ) => Effect.Effect<PresignedUploadAuthorization, E, R>;
93
+
94
+ /** As {@link UploadHandlerOptions.onUploadCompleted}; callbacks are verified with Ed25519. */
95
+ readonly onUploadCompleted?:
96
+ | ((event: UploadCompleted) => Effect.Effect<void, E2, R2>)
97
+ | undefined;
98
+ }
99
+
100
+ /**
101
+ * The server side of browser uploads: a route that authorizes uploads and receives the store's
102
+ * upload-completed callbacks.
103
+ *
104
+ * ```ts
105
+ * export const POST = HttpEffect.toWebHandlerLayer(
106
+ * BlobUploads.use((uploads) =>
107
+ * uploads.handler({ onBeforeGenerateToken: authorize, onUploadCompleted: saveAvatar }),
108
+ * ).pipe(Effect.catchTag("Unauthorized", () => HttpServerResponse.empty({ status: 401 }))),
109
+ * AppLayer,
110
+ * ).handler;
111
+ * ```
112
+ */
113
+ export class BlobUploads extends Context.Service<
114
+ BlobUploads,
115
+ {
116
+ /**
117
+ * Sign a client token with the store's read-write token. Defaults `validUntil` to 30 seconds
118
+ * from now; an `ifMatch` without `allowOverwrite` allows overwriting.
119
+ */
120
+ readonly generateClientToken: (
121
+ options: ClientTokenOptions,
122
+ ) => Effect.Effect<ClientToken, BlobReadWriteTokenRequired>;
123
+
124
+ /**
125
+ * Handle one request to a client-token upload route (`handleUpload`): answer
126
+ * `blob.generate-client-token` with `{ type, clientToken }` (valid for one hour unless
127
+ * `validUntil` says otherwise) and acknowledge a verified `blob.upload-completed` with
128
+ * `{ type, response: "ok" }`. Requires a read-write token.
129
+ */
130
+ readonly handler: <E, R, E2 = never, R2 = never>(
131
+ options: UploadHandlerOptions<E, R, E2, R2>,
132
+ ) => Effect.Effect<
133
+ HttpServerResponse.HttpServerResponse,
134
+ E | E2 | BlobUploadEventRejected | BlobReadWriteTokenRequired,
135
+ HttpServerRequest.HttpServerRequest | R | R2
136
+ >;
137
+
138
+ /**
139
+ * Handle one request to a presigned upload route (`handleUploadPresigned`): answer
140
+ * `blob.generate-presigned-url` with `{ type, presignedUrlPayload }` and acknowledge a
141
+ * `blob.upload-completed` verified against `BLOB_WEBHOOK_PUBLIC_KEY`. Works with OIDC.
142
+ */
143
+ readonly presignedHandler: <E, R, E2 = never, R2 = never>(
144
+ options: PresignedUploadHandlerOptions<E, R, E2, R2>,
145
+ ) => Effect.Effect<
146
+ HttpServerResponse.HttpServerResponse,
147
+ E | E2 | BlobUploadEventRejected | BlobPresignFailed,
148
+ HttpServerRequest.HttpServerRequest | R | R2
149
+ >;
150
+ }
151
+ >()("@effect-vercel/blob/BlobUploads") {}