@pithy-sh/cloudflare 0.1.2 → 0.1.4

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 (139) hide show
  1. package/dist/ai/aiManager.d.ts +101 -0
  2. package/dist/ai/aiManager.d.ts.map +1 -0
  3. package/dist/ai/aiManager.js +150 -0
  4. package/dist/ai/vectorizeManager.d.ts +69 -0
  5. package/dist/ai/vectorizeManager.d.ts.map +1 -0
  6. package/dist/ai/vectorizeManager.js +103 -0
  7. package/dist/ai/vectorizeProvisioner.d.ts +125 -0
  8. package/dist/ai/vectorizeProvisioner.d.ts.map +1 -0
  9. package/dist/ai/vectorizeProvisioner.js +197 -0
  10. package/dist/client/accounts.d.ts +50 -0
  11. package/dist/client/accounts.d.ts.map +1 -0
  12. package/dist/client/accounts.js +57 -0
  13. package/dist/client/clients.d.ts +124 -0
  14. package/dist/client/clients.d.ts.map +1 -0
  15. package/dist/client/clients.js +218 -0
  16. package/dist/client/errors.d.ts +75 -0
  17. package/dist/client/errors.d.ts.map +1 -0
  18. package/dist/client/errors.js +100 -0
  19. package/dist/client/manager.d.ts +47 -0
  20. package/dist/client/manager.d.ts.map +1 -0
  21. package/dist/client/manager.js +53 -0
  22. package/dist/d1/d1Manager.d.ts +76 -0
  23. package/dist/d1/d1Manager.d.ts.map +1 -0
  24. package/dist/d1/d1Manager.js +130 -0
  25. package/dist/d1/d1PreparedStatement.d.ts +26 -0
  26. package/dist/d1/d1PreparedStatement.d.ts.map +1 -0
  27. package/dist/d1/d1PreparedStatement.js +93 -0
  28. package/dist/d1/d1Provisioner.d.ts +30 -0
  29. package/dist/d1/d1Provisioner.d.ts.map +1 -0
  30. package/dist/d1/d1Provisioner.js +69 -0
  31. package/dist/email/emailRoutingManager.d.ts +38 -0
  32. package/dist/email/emailRoutingManager.d.ts.map +1 -0
  33. package/dist/email/emailRoutingManager.js +126 -0
  34. package/dist/email/emailSendManager.d.ts +47 -0
  35. package/dist/email/emailSendManager.d.ts.map +1 -0
  36. package/dist/email/emailSendManager.js +54 -0
  37. package/dist/env/devVars.d.ts +67 -0
  38. package/dist/env/devVars.d.ts.map +1 -0
  39. package/dist/env/devVars.js +86 -0
  40. package/dist/hostnames/customHostnamesManager.d.ts +53 -0
  41. package/dist/hostnames/customHostnamesManager.d.ts.map +1 -0
  42. package/dist/hostnames/customHostnamesManager.js +86 -0
  43. package/dist/kv/kvManager.d.ts +78 -0
  44. package/dist/kv/kvManager.d.ts.map +1 -0
  45. package/dist/kv/kvManager.js +137 -0
  46. package/dist/kv/kvProvisioner.d.ts +30 -0
  47. package/dist/kv/kvProvisioner.d.ts.map +1 -0
  48. package/dist/kv/kvProvisioner.js +73 -0
  49. package/dist/media/assetSeeder.d.ts +33 -0
  50. package/dist/media/assetSeeder.d.ts.map +1 -0
  51. package/dist/media/assetSeeder.js +72 -0
  52. package/dist/media/imageManager.d.ts +36 -0
  53. package/dist/media/imageManager.d.ts.map +1 -0
  54. package/dist/media/imageManager.js +105 -0
  55. package/dist/media/ownership.d.ts +42 -0
  56. package/dist/media/ownership.d.ts.map +1 -0
  57. package/dist/media/ownership.js +48 -0
  58. package/dist/media/streamManager.d.ts +85 -0
  59. package/dist/media/streamManager.d.ts.map +1 -0
  60. package/dist/media/streamManager.js +147 -0
  61. package/dist/queue/queueManager.d.ts +86 -0
  62. package/dist/queue/queueManager.d.ts.map +1 -0
  63. package/dist/queue/queueManager.js +116 -0
  64. package/dist/r2/r2Credentials.d.ts +14 -0
  65. package/dist/r2/r2Credentials.d.ts.map +1 -0
  66. package/dist/r2/r2Credentials.js +15 -0
  67. package/dist/r2/r2Manager.d.ts +228 -0
  68. package/dist/r2/r2Manager.d.ts.map +1 -0
  69. package/dist/r2/r2Manager.js +407 -0
  70. package/dist/r2/r2Provisioner.d.ts +41 -0
  71. package/dist/r2/r2Provisioner.d.ts.map +1 -0
  72. package/dist/r2/r2Provisioner.js +88 -0
  73. package/dist/secrets/secretsStoreManager.d.ts +84 -0
  74. package/dist/secrets/secretsStoreManager.d.ts.map +1 -0
  75. package/dist/secrets/secretsStoreManager.js +149 -0
  76. package/dist/secrets/secretsStores.d.ts +49 -0
  77. package/dist/secrets/secretsStores.d.ts.map +1 -0
  78. package/dist/secrets/secretsStores.js +67 -0
  79. package/dist/test-utils/emailRoutingRules.d.ts +41 -0
  80. package/dist/test-utils/emailRoutingRules.d.ts.map +1 -0
  81. package/dist/test-utils/emailRoutingRules.js +91 -0
  82. package/dist/test-utils/fixtureReportSetup.d.ts +27 -0
  83. package/dist/test-utils/fixtureReportSetup.d.ts.map +1 -0
  84. package/dist/test-utils/fixtureReportSetup.js +32 -0
  85. package/dist/test-utils/fixtures.d.ts +226 -0
  86. package/dist/test-utils/fixtures.d.ts.map +1 -0
  87. package/dist/test-utils/fixtures.js +279 -0
  88. package/dist/test-utils/harness.d.ts +194 -0
  89. package/dist/test-utils/harness.d.ts.map +1 -0
  90. package/dist/test-utils/harness.js +353 -0
  91. package/dist/test-utils/inboundRecorder.d.ts +93 -0
  92. package/dist/test-utils/inboundRecorder.d.ts.map +1 -0
  93. package/dist/test-utils/inboundRecorder.js +143 -0
  94. package/dist/test-utils/integrationSetup.d.ts +24 -0
  95. package/dist/test-utils/integrationSetup.d.ts.map +1 -0
  96. package/dist/test-utils/integrationSetup.js +39 -0
  97. package/dist/test-utils/reap.d.ts +114 -0
  98. package/dist/test-utils/reap.d.ts.map +1 -0
  99. package/dist/test-utils/reap.js +187 -0
  100. package/dist/tokens/accountTokensManager.d.ts +159 -0
  101. package/dist/tokens/accountTokensManager.d.ts.map +1 -0
  102. package/dist/tokens/accountTokensManager.js +282 -0
  103. package/dist/tokens/permissions.d.ts +38 -0
  104. package/dist/tokens/permissions.d.ts.map +1 -0
  105. package/dist/tokens/permissions.js +52 -0
  106. package/dist/tokens/profiles.d.ts +104 -0
  107. package/dist/tokens/profiles.d.ts.map +1 -0
  108. package/dist/tokens/profiles.js +139 -0
  109. package/dist/turnstile/turnstileManager.d.ts +77 -0
  110. package/dist/turnstile/turnstileManager.d.ts.map +1 -0
  111. package/dist/turnstile/turnstileManager.js +140 -0
  112. package/dist/user/userManager.d.ts +38 -0
  113. package/dist/user/userManager.d.ts.map +1 -0
  114. package/dist/user/userManager.js +60 -0
  115. package/dist/workers/buildsManager.d.ts +118 -0
  116. package/dist/workers/buildsManager.d.ts.map +1 -0
  117. package/dist/workers/buildsManager.js +239 -0
  118. package/dist/workers/buildsTypes.d.ts +104 -0
  119. package/dist/workers/buildsTypes.d.ts.map +1 -0
  120. package/dist/workers/buildsTypes.js +89 -0
  121. package/dist/workers/workersBuildEvent.d.ts +66 -0
  122. package/dist/workers/workersBuildEvent.d.ts.map +1 -0
  123. package/dist/workers/workersBuildEvent.js +49 -0
  124. package/dist/workers/workersManager.d.ts +138 -0
  125. package/dist/workers/workersManager.d.ts.map +1 -0
  126. package/dist/workers/workersManager.js +314 -0
  127. package/dist/workers/workersProvisioner.d.ts +82 -0
  128. package/dist/workers/workersProvisioner.d.ts.map +1 -0
  129. package/dist/workers/workersProvisioner.js +108 -0
  130. package/dist/workflows/stepFailure.d.ts +182 -0
  131. package/dist/workflows/stepFailure.d.ts.map +1 -0
  132. package/dist/workflows/stepFailure.js +229 -0
  133. package/dist/workflows/workflowsClient.d.ts +89 -0
  134. package/dist/workflows/workflowsClient.d.ts.map +1 -0
  135. package/dist/workflows/workflowsClient.js +158 -0
  136. package/dist/zones/zonesManager.d.ts +52 -0
  137. package/dist/zones/zonesManager.d.ts.map +1 -0
  138. package/dist/zones/zonesManager.js +76 -0
  139. package/package.json +18 -9
@@ -0,0 +1,228 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ import { CloudflareManager, type CloudflareManagerConfig } from "../client/manager";
5
+ import { R2Credentials } from "./r2Credentials";
6
+ /**
7
+ * Config for the R2 manager: the shared client config plus the S3-compatible credential pair and
8
+ * the bucket it targets. R2 signs presigned URLs with the S3 keys, not the CF API token — both
9
+ * come from config (no environment coupling).
10
+ */
11
+ export interface R2ManagerConfig extends CloudflareManagerConfig, R2Credentials {
12
+ /** The R2 bucket all object operations target. */
13
+ bucketName: string;
14
+ }
15
+ /** How long a presigned URL stays valid. Shared by every presign method so the option reads the same. */
16
+ export interface PresignOptions {
17
+ /** Lifetime in seconds. Defaults to one hour — long enough for a large upload, short enough to leak little. */
18
+ expiresIn?: number;
19
+ }
20
+ /** Presign options for one multipart part, which may additionally pin the part's exact byte count. */
21
+ export interface PresignPartOptions extends PresignOptions {
22
+ /**
23
+ * Exact byte count the client must send. Omitted by default — see {@link CloudflareR2Manager.presignUploadPart}
24
+ * for why signing a part length is opt-in where signing a whole-object length is not.
25
+ */
26
+ contentLength?: number;
27
+ }
28
+ /** What emptying a bucket reclaimed — the counts a teardown reports and an audit event records. */
29
+ export interface R2BucketDrain {
30
+ /** How many stored objects were deleted. */
31
+ objectsDeleted: number;
32
+ /** How many abandoned multipart uploads were aborted before the objects were drained. */
33
+ uploadsAborted: number;
34
+ }
35
+ /** Which slice of a bucket's keys to list, and how many to return. */
36
+ export interface ListObjectsOptions {
37
+ /** Only return keys starting with this prefix. Omitted lists the whole bucket. */
38
+ prefix?: string;
39
+ /** Continuation cursor from a previous page's `cursor`. Omitted starts at the beginning. */
40
+ cursor?: string;
41
+ /** Page size. R2 caps this at 1000 and returns a `cursor` when more remain. */
42
+ maxKeys?: number;
43
+ }
44
+ /** One completed part, as the client reports it back after a presigned `PUT` to a part URL. */
45
+ export interface CompletedPart {
46
+ /** The part's 1-based index, matching the `partNumber` the part URL was presigned for. */
47
+ partNumber: number;
48
+ /** The `ETag` response header R2 returned for that part, verbatim — quotes included. */
49
+ etag: string;
50
+ }
51
+ /** An object's metadata as R2 reports it, without fetching the body. */
52
+ export declare const R2ObjectHead: z.ZodObject<{
53
+ size: z.ZodNumber;
54
+ etag: z.ZodString;
55
+ contentType: z.ZodOptional<z.ZodString>;
56
+ uploaded: z.ZodOptional<z.ZodDate>;
57
+ }, z.core.$strip>;
58
+ export type R2ObjectHead = z.output<typeof R2ObjectHead>;
59
+ /** One part already uploaded against an in-flight multipart upload. */
60
+ export declare const R2UploadedPart: z.ZodObject<{
61
+ partNumber: z.ZodNumber;
62
+ etag: z.ZodString;
63
+ size: z.ZodNumber;
64
+ }, z.core.$strip>;
65
+ export type R2UploadedPart = z.output<typeof R2UploadedPart>;
66
+ /** One page of object keys from a bucket listing. */
67
+ export declare const R2ObjectListing: z.ZodObject<{
68
+ keys: z.ZodArray<z.ZodString>;
69
+ cursor: z.ZodOptional<z.ZodString>;
70
+ }, z.core.$strip>;
71
+ export type R2ObjectListing = z.output<typeof R2ObjectListing>;
72
+ /**
73
+ * A multipart upload that was started and never completed or aborted.
74
+ *
75
+ * Abandoned uploads are invisible and not free — R2 stores and bills their parts, and a bucket holding
76
+ * one cannot be deleted at all.
77
+ */
78
+ export declare const R2PendingUpload: z.ZodObject<{
79
+ key: z.ZodString;
80
+ uploadId: z.ZodString;
81
+ initiated: z.ZodOptional<z.ZodDate>;
82
+ }, z.core.$strip>;
83
+ export type R2PendingUpload = z.output<typeof R2PendingUpload>;
84
+ /**
85
+ * Out-of-Worker Cloudflare R2 access over the S3 protocol: presigned URLs the client uses to move
86
+ * bytes directly, plus the server-side object and multipart operations that surround them. Inside a
87
+ * Worker you use the R2 binding for object reads and writes; this manager is the REST/S3 counterpart,
88
+ * addressed by bucket name, and the only path to *presigned* multipart — the binding's multipart API
89
+ * streams bytes through the Worker, which defeats the point.
90
+ *
91
+ * Only `uploadPart` is presigned. Create, complete, abort, list, head, copy and delete are server-side
92
+ * calls the holder of the credentials makes directly — presigning them would hand a client authority
93
+ * it has no reason to hold.
94
+ */
95
+ export declare class CloudflareR2Manager extends CloudflareManager {
96
+ private readonly bucketName;
97
+ private readonly accessKeyId;
98
+ private readonly secretAccessKey;
99
+ private s3Client;
100
+ constructor(config: R2ManagerConfig);
101
+ /** The S3-compatible client for R2, lazily created and cached for the manager's lifetime. */
102
+ private getS3Client;
103
+ /**
104
+ * Presign a PUT URL for uploading one whole object. Valid for one hour unless `expiresIn` says otherwise.
105
+ *
106
+ * **`ContentLength` is signed; `ContentType` is not.** The length lands in `X-Amz-SignedHeaders`, so
107
+ * the client must send exactly that byte count — the signature is the enforcement, and that is
108
+ * deliberate for a whole-object upload: the caller already knows the size (it is what a quota check
109
+ * was run against), so signing it stops a client from spending more storage than it was granted.
110
+ *
111
+ * The type cannot be enforced this way at all. `@aws-sdk/s3-request-presigner` marks `content-type`
112
+ * unsignable before signing (`prepareRequest` → `unsignableHeaders.add("content-type")`), because a
113
+ * browser rewrites the header on a form or an XHR upload and a signature over it would break every
114
+ * such client. It therefore never reaches `X-Amz-SignedHeaders`, and R2 stores whatever type the
115
+ * client's PUT actually carries. `contentType` here is documentation of intent, not a constraint —
116
+ * unlike {@link createMultipartUpload}, which is a server-side call and does pin the type it sends.
117
+ * Anything keyed on an object's type must read it back with {@link headObject} after the upload,
118
+ * never from what the caller declared.
119
+ */
120
+ createUploadUrl(key: string, contentType: string, contentLength: number, options?: PresignOptions): Promise<string>;
121
+ /** Presign a GET URL for downloading one object. Valid for one hour unless `expiresIn` says otherwise. */
122
+ createDownloadUrl(key: string, options?: PresignOptions): Promise<string>;
123
+ /**
124
+ * Open a multipart upload and return its `uploadId`. Server-side — no presigning. The id is the
125
+ * handle every later part, completion, abort and resume is addressed by, so a caller that means to
126
+ * support resuming must persist it.
127
+ *
128
+ * R2's multipart shape: at most 10,000 parts, every part but the last at least 5 MiB, each at most
129
+ * 5 GiB. Nothing here enforces that — R2 rejects a violating `complete`, and the part-size policy
130
+ * belongs to the layer that chose the part size.
131
+ */
132
+ createMultipartUpload(key: string, contentType: string): Promise<string>;
133
+ /**
134
+ * Presign a PUT URL for one part of a multipart upload. This is the only multipart step a client
135
+ * ever touches, and therefore the only one presigned.
136
+ *
137
+ * **`ContentLength` is omitted by default**, unlike `createUploadUrl`. A signed length forces the
138
+ * client to send exactly that many bytes, and for a multipart upload the last part's length is
139
+ * `total - partSize * (n - 1)` — a different number from every other part. Signing it would mean
140
+ * either knowing the total size at mint time (a resumable or streaming upload does not) or minting
141
+ * the final part's URL through a separate code path. Neither earns its complexity: R2 already
142
+ * enforces the 5 MiB floor and 5 GiB ceiling per part, and the whole-object size is enforced where
143
+ * it matters — at `completeMultipartUpload`, against the parts R2 actually stored. A caller that
144
+ * does know the exact part size may still pin it by passing `contentLength`.
145
+ */
146
+ presignUploadPart(key: string, uploadId: string, partNumber: number, options?: PresignPartOptions): Promise<string>;
147
+ /**
148
+ * Assemble the uploaded parts into one object. Server-side — the client only ever reports the
149
+ * `{ partNumber, etag }` pairs its part PUTs returned.
150
+ *
151
+ * Parts are sorted ascending before the call: S3 rejects an out-of-order part list, and the order a
152
+ * client finishes concurrent part uploads in is not the order they belong in. ETags pass through
153
+ * verbatim — they are opaque to us, and R2 compares them byte for byte.
154
+ */
155
+ completeMultipartUpload(key: string, uploadId: string, parts: readonly CompletedPart[]): Promise<void>;
156
+ /**
157
+ * Discard an in-flight multipart upload and the parts already stored under it. Idempotent — an
158
+ * upload id R2 has already forgotten is not an error, so a sweep or a retried teardown can re-run.
159
+ */
160
+ abortMultipartUpload(key: string, uploadId: string): Promise<void>;
161
+ /**
162
+ * List the parts already stored against an in-flight upload, ascending. This is what makes an upload
163
+ * resumable: a client that lost its progress asks which parts landed and re-sends only the rest.
164
+ *
165
+ * Pagination is drained here rather than surfaced. An upload holds at most 10,000 parts and a page
166
+ * holds 1000, so the loop is bounded at ten calls — a cursor would be API surface for no gain.
167
+ */
168
+ listParts(key: string, uploadId: string): Promise<R2UploadedPart[]>;
169
+ /**
170
+ * Read one object's metadata without transferring its body. Returns `null` when the object is
171
+ * absent — a missing object is an answer, not a failure, and the caller decides what it means.
172
+ */
173
+ headObject(key: string): Promise<R2ObjectHead | null>;
174
+ /**
175
+ * Every multipart upload started in this bucket and never completed or aborted.
176
+ *
177
+ * Abandoned uploads are invisible and not free: R2 stores and bills the parts, and a bucket holding one
178
+ * cannot be deleted at all. Nothing else can find them — `listObjects` does not show an upload that never
179
+ * assembled, and `listParts` needs the `uploadId` you are trying to discover. So this is the only route to
180
+ * reclaiming a bucket, and the only way an orphan sweep can see the cost it is meant to reclaim.
181
+ *
182
+ * Fully drained rather than cursor-paged: a caller wants all of them, and the count is bounded by how many
183
+ * uploads were abandoned rather than by how much data the bucket holds.
184
+ */
185
+ listMultipartUploads(prefix?: string): Promise<R2PendingUpload[]>;
186
+ /**
187
+ * List one page of object keys. Pagination is caller-driven: pass the returned `cursor` back to get
188
+ * the next page, and stop when it is absent. Unlike `listParts` a bucket has no bounded size, so
189
+ * draining it here would be an unbounded loop with no way for the caller to stop early.
190
+ */
191
+ listObjects(options?: ListObjectsOptions): Promise<R2ObjectListing>;
192
+ /**
193
+ * Copy an object within this bucket, server-side — the bytes never leave R2.
194
+ *
195
+ * `CopySource` is `/<bucket>/<key>` with the key percent-encoded per path segment. The SDK does not
196
+ * encode it, and a key containing a space or `?` produces a malformed source header otherwise;
197
+ * encoding whole would destroy the `/` separators a nested key depends on.
198
+ */
199
+ copyObject(sourceKey: string, destKey: string): Promise<void>;
200
+ /**
201
+ * Delete one object. Idempotent by protocol — S3 answers a delete of an absent key with a success,
202
+ * so teardown and orphan sweeps can re-run safely.
203
+ */
204
+ deleteObject(key: string): Promise<void>;
205
+ /**
206
+ * Delete every object in this bucket and abort every multipart upload still in flight.
207
+ *
208
+ * **R2 refuses to delete a bucket that is not empty**, so this is the step that has to run before
209
+ * `CloudflareR2Provisioner.deleteBucket` — and it is the reason a teardown needs the S3 key pair at
210
+ * all: the CF API has no object plane, so nothing but this protocol can empty a bucket.
211
+ *
212
+ * Two different things hold a bucket, and only one of them is visible. Stored objects show up in a
213
+ * listing; the parts of an upload that was started and never completed or aborted do not, and they
214
+ * block the delete just the same. So the aborts go first: draining the keys of a bucket that still
215
+ * carries a dangling upload leaves it empty-looking and undeletable, which is the confusing failure.
216
+ *
217
+ * **Destructive and unrecoverable**, and it does not ask — the caller owns the confirmation. Idempotent:
218
+ * an already-empty bucket is a no-op reporting zeroes, so a retried teardown is safe.
219
+ */
220
+ emptyBucket(): Promise<R2BucketDrain>;
221
+ getServiceType(): string;
222
+ /**
223
+ * Prove access by reading the bucket record over the CF API (token + account + bucket existence),
224
+ * not merely that an S3 client could be built. Never throws.
225
+ */
226
+ validateServiceAccess(): Promise<boolean>;
227
+ }
228
+ //# sourceMappingURL=r2Manager.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"r2Manager.d.ts","sourceRoot":"","sources":["../../src/r2/r2Manager.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,407 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { CloudflareNotConfiguredError, cloudflareRequest, decodeResponse } from "../client/errors.js";
4
+ import { CloudflareManager } from "../client/manager.js";
5
+ import { R2Credentials } from "./r2Credentials.js";
6
+ import { z } from "zod";
7
+ import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CopyObjectCommand, CreateMultipartUploadCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListMultipartUploadsCommand, ListObjectsV2Command, ListPartsCommand, PutObjectCommand, S3Client, UploadPartCommand } from "@aws-sdk/client-s3";
8
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
9
+ //#region src/r2/r2Manager.ts
10
+ /** How long a presigned R2 URL stays valid, in seconds (1 hour), when the caller names no lifetime. */
11
+ const PRESIGN_EXPIRY_SECONDS = 3600;
12
+ /** How many parts one `ListParts` page returns. S3's own cap; the drain loop below relies on it. */
13
+ const LIST_PARTS_PAGE_SIZE = 1e3;
14
+ /** An object's metadata as R2 reports it, without fetching the body. */
15
+ const R2ObjectHead = z.object({
16
+ size: z.number().int().nonnegative().describe("The object's size in bytes."),
17
+ etag: z.string().min(1).describe("R2's entity tag for this object version, verbatim — quotes included."),
18
+ contentType: z.string().min(1).optional().describe("The stored `Content-Type`. Absent when the object was written without one."),
19
+ uploaded: z.date().optional().describe("When R2 last wrote the object. Absent when R2 reports no timestamp.")
20
+ }).describe("Metadata for one R2 object, read with a HEAD — no body transfer.");
21
+ /** One part already uploaded against an in-flight multipart upload. */
22
+ const R2UploadedPart = z.object({
23
+ partNumber: z.number().int().min(1).describe("The part's 1-based index within the upload."),
24
+ etag: z.string().min(1).describe("The part's entity tag, verbatim. Pass it back unchanged to complete the upload."),
25
+ size: z.number().int().nonnegative().describe("The part's size in bytes, as R2 stored it.")
26
+ }).describe("One uploaded part of a multipart upload — the unit a resumed upload skips re-sending.");
27
+ /** One page of object keys from a bucket listing. */
28
+ const R2ObjectListing = z.object({
29
+ keys: z.array(z.string().min(1)).describe("The object keys in this page, in R2's lexicographic order."),
30
+ cursor: z.string().min(1).optional().describe("Continuation cursor for the next page. Absent means this page was the last.")
31
+ }).describe("One page of an R2 bucket listing. Pagination is caller-driven — pass `cursor` back to advance.");
32
+ /**
33
+ * A multipart upload that was started and never completed or aborted.
34
+ *
35
+ * Abandoned uploads are invisible and not free — R2 stores and bills their parts, and a bucket holding
36
+ * one cannot be deleted at all.
37
+ */
38
+ const R2PendingUpload = z.object({
39
+ key: z.string().min(1).describe("The object key this upload would have assembled into."),
40
+ uploadId: z.string().min(1).describe("The id `abortMultipartUpload` needs in order to reclaim it."),
41
+ initiated: z.date().optional().describe("When the upload was started, for age-based reclamation.")
42
+ }).describe("A multipart upload started and never completed or aborted — billed, invisible, and blocking.");
43
+ /**
44
+ * Whether a thrown S3 error is a 404.
45
+ *
46
+ * `isNotFoundError` from `client/errors` reads `error.status`, which the `cloudflare` SDK sets and the
47
+ * AWS SDK does not — the AWS SDK reports the code on `$metadata.httpStatusCode` and names the shape
48
+ * `NotFound` (HEAD) or `NoSuchKey` (GET). So the S3 protocol needs its own check; sharing one would
49
+ * silently classify every missing object as a request failure.
50
+ */
51
+ function isS3NotFound(error) {
52
+ if (typeof error !== "object" || error === null) return false;
53
+ const name = error.name;
54
+ if (name === "NotFound" || name === "NoSuchKey") return true;
55
+ return error.$metadata?.httpStatusCode === 404;
56
+ }
57
+ /**
58
+ * Out-of-Worker Cloudflare R2 access over the S3 protocol: presigned URLs the client uses to move
59
+ * bytes directly, plus the server-side object and multipart operations that surround them. Inside a
60
+ * Worker you use the R2 binding for object reads and writes; this manager is the REST/S3 counterpart,
61
+ * addressed by bucket name, and the only path to *presigned* multipart — the binding's multipart API
62
+ * streams bytes through the Worker, which defeats the point.
63
+ *
64
+ * Only `uploadPart` is presigned. Create, complete, abort, list, head, copy and delete are server-side
65
+ * calls the holder of the credentials makes directly — presigning them would hand a client authority
66
+ * it has no reason to hold.
67
+ */
68
+ var CloudflareR2Manager = class extends CloudflareManager {
69
+ bucketName;
70
+ accessKeyId;
71
+ secretAccessKey;
72
+ s3Client = null;
73
+ constructor(config) {
74
+ super(config);
75
+ if (!config.bucketName) throw new CloudflareNotConfiguredError({ detail: "Missing bucketName for R2 access." });
76
+ const credentials = R2Credentials.safeParse(config);
77
+ if (!credentials.success) throw new CloudflareNotConfiguredError({ detail: `Invalid R2 credentials: ${credentials.error.message}` });
78
+ this.bucketName = config.bucketName;
79
+ this.accessKeyId = config.accessKeyId;
80
+ this.secretAccessKey = config.secretAccessKey;
81
+ }
82
+ /** The S3-compatible client for R2, lazily created and cached for the manager's lifetime. */
83
+ getS3Client() {
84
+ if (!this.s3Client) this.s3Client = new S3Client({
85
+ region: "auto",
86
+ endpoint: `https://${this.accountId}.r2.cloudflarestorage.com`,
87
+ credentials: {
88
+ accessKeyId: this.accessKeyId,
89
+ secretAccessKey: this.secretAccessKey
90
+ }
91
+ });
92
+ return this.s3Client;
93
+ }
94
+ /**
95
+ * Presign a PUT URL for uploading one whole object. Valid for one hour unless `expiresIn` says otherwise.
96
+ *
97
+ * **`ContentLength` is signed; `ContentType` is not.** The length lands in `X-Amz-SignedHeaders`, so
98
+ * the client must send exactly that byte count — the signature is the enforcement, and that is
99
+ * deliberate for a whole-object upload: the caller already knows the size (it is what a quota check
100
+ * was run against), so signing it stops a client from spending more storage than it was granted.
101
+ *
102
+ * The type cannot be enforced this way at all. `@aws-sdk/s3-request-presigner` marks `content-type`
103
+ * unsignable before signing (`prepareRequest` → `unsignableHeaders.add("content-type")`), because a
104
+ * browser rewrites the header on a form or an XHR upload and a signature over it would break every
105
+ * such client. It therefore never reaches `X-Amz-SignedHeaders`, and R2 stores whatever type the
106
+ * client's PUT actually carries. `contentType` here is documentation of intent, not a constraint —
107
+ * unlike {@link createMultipartUpload}, which is a server-side call and does pin the type it sends.
108
+ * Anything keyed on an object's type must read it back with {@link headObject} after the upload,
109
+ * never from what the caller declared.
110
+ */
111
+ async createUploadUrl(key, contentType, contentLength, options = {}) {
112
+ return cloudflareRequest(`R2 presign upload for '${key}'`, () => {
113
+ const command = new PutObjectCommand({
114
+ Bucket: this.bucketName,
115
+ Key: key,
116
+ ContentType: contentType,
117
+ ContentLength: contentLength
118
+ });
119
+ return getSignedUrl(this.getS3Client(), command, { expiresIn: options.expiresIn ?? PRESIGN_EXPIRY_SECONDS });
120
+ });
121
+ }
122
+ /** Presign a GET URL for downloading one object. Valid for one hour unless `expiresIn` says otherwise. */
123
+ async createDownloadUrl(key, options = {}) {
124
+ return cloudflareRequest(`R2 presign download for '${key}'`, () => {
125
+ const command = new GetObjectCommand({
126
+ Bucket: this.bucketName,
127
+ Key: key
128
+ });
129
+ return getSignedUrl(this.getS3Client(), command, { expiresIn: options.expiresIn ?? PRESIGN_EXPIRY_SECONDS });
130
+ });
131
+ }
132
+ /**
133
+ * Open a multipart upload and return its `uploadId`. Server-side — no presigning. The id is the
134
+ * handle every later part, completion, abort and resume is addressed by, so a caller that means to
135
+ * support resuming must persist it.
136
+ *
137
+ * R2's multipart shape: at most 10,000 parts, every part but the last at least 5 MiB, each at most
138
+ * 5 GiB. Nothing here enforces that — R2 rejects a violating `complete`, and the part-size policy
139
+ * belongs to the layer that chose the part size.
140
+ */
141
+ async createMultipartUpload(key, contentType) {
142
+ return cloudflareRequest(`R2 create multipart upload for '${key}'`, async () => {
143
+ const response = await this.getS3Client().send(new CreateMultipartUploadCommand({
144
+ Bucket: this.bucketName,
145
+ Key: key,
146
+ ContentType: contentType
147
+ }));
148
+ return decodeResponse(z.string().min(1).describe("The multipart upload id."), response.UploadId, `R2 create multipart upload for '${key}'`);
149
+ });
150
+ }
151
+ /**
152
+ * Presign a PUT URL for one part of a multipart upload. This is the only multipart step a client
153
+ * ever touches, and therefore the only one presigned.
154
+ *
155
+ * **`ContentLength` is omitted by default**, unlike `createUploadUrl`. A signed length forces the
156
+ * client to send exactly that many bytes, and for a multipart upload the last part's length is
157
+ * `total - partSize * (n - 1)` — a different number from every other part. Signing it would mean
158
+ * either knowing the total size at mint time (a resumable or streaming upload does not) or minting
159
+ * the final part's URL through a separate code path. Neither earns its complexity: R2 already
160
+ * enforces the 5 MiB floor and 5 GiB ceiling per part, and the whole-object size is enforced where
161
+ * it matters — at `completeMultipartUpload`, against the parts R2 actually stored. A caller that
162
+ * does know the exact part size may still pin it by passing `contentLength`.
163
+ */
164
+ async presignUploadPart(key, uploadId, partNumber, options = {}) {
165
+ return cloudflareRequest(`R2 presign part ${partNumber} for '${key}'`, () => {
166
+ const command = new UploadPartCommand({
167
+ Bucket: this.bucketName,
168
+ Key: key,
169
+ UploadId: uploadId,
170
+ PartNumber: partNumber,
171
+ ...options.contentLength === void 0 ? {} : { ContentLength: options.contentLength }
172
+ });
173
+ return getSignedUrl(this.getS3Client(), command, { expiresIn: options.expiresIn ?? PRESIGN_EXPIRY_SECONDS });
174
+ });
175
+ }
176
+ /**
177
+ * Assemble the uploaded parts into one object. Server-side — the client only ever reports the
178
+ * `{ partNumber, etag }` pairs its part PUTs returned.
179
+ *
180
+ * Parts are sorted ascending before the call: S3 rejects an out-of-order part list, and the order a
181
+ * client finishes concurrent part uploads in is not the order they belong in. ETags pass through
182
+ * verbatim — they are opaque to us, and R2 compares them byte for byte.
183
+ */
184
+ async completeMultipartUpload(key, uploadId, parts) {
185
+ await cloudflareRequest(`R2 complete multipart upload for '${key}'`, async () => {
186
+ const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
187
+ await this.getS3Client().send(new CompleteMultipartUploadCommand({
188
+ Bucket: this.bucketName,
189
+ Key: key,
190
+ UploadId: uploadId,
191
+ MultipartUpload: { Parts: ordered.map((p) => ({
192
+ PartNumber: p.partNumber,
193
+ ETag: p.etag
194
+ })) }
195
+ }));
196
+ });
197
+ }
198
+ /**
199
+ * Discard an in-flight multipart upload and the parts already stored under it. Idempotent — an
200
+ * upload id R2 has already forgotten is not an error, so a sweep or a retried teardown can re-run.
201
+ */
202
+ async abortMultipartUpload(key, uploadId) {
203
+ await cloudflareRequest(`R2 abort multipart upload for '${key}'`, async () => {
204
+ try {
205
+ await this.getS3Client().send(new AbortMultipartUploadCommand({
206
+ Bucket: this.bucketName,
207
+ Key: key,
208
+ UploadId: uploadId
209
+ }));
210
+ } catch (error) {
211
+ if (isS3NotFound(error)) return;
212
+ throw error;
213
+ }
214
+ });
215
+ }
216
+ /**
217
+ * List the parts already stored against an in-flight upload, ascending. This is what makes an upload
218
+ * resumable: a client that lost its progress asks which parts landed and re-sends only the rest.
219
+ *
220
+ * Pagination is drained here rather than surfaced. An upload holds at most 10,000 parts and a page
221
+ * holds 1000, so the loop is bounded at ten calls — a cursor would be API surface for no gain.
222
+ */
223
+ async listParts(key, uploadId) {
224
+ return cloudflareRequest(`R2 list parts for '${key}'`, async () => {
225
+ const parts = [];
226
+ let marker;
227
+ do {
228
+ const response = await this.getS3Client().send(new ListPartsCommand({
229
+ Bucket: this.bucketName,
230
+ Key: key,
231
+ UploadId: uploadId,
232
+ MaxParts: LIST_PARTS_PAGE_SIZE,
233
+ PartNumberMarker: marker
234
+ }));
235
+ for (const part of response.Parts ?? []) parts.push(decodeResponse(R2UploadedPart, {
236
+ partNumber: part.PartNumber,
237
+ etag: part.ETag,
238
+ size: part.Size
239
+ }, `R2 list parts for '${key}'`));
240
+ marker = response.IsTruncated ? response.NextPartNumberMarker : void 0;
241
+ } while (marker);
242
+ return parts.sort((a, b) => a.partNumber - b.partNumber);
243
+ });
244
+ }
245
+ /**
246
+ * Read one object's metadata without transferring its body. Returns `null` when the object is
247
+ * absent — a missing object is an answer, not a failure, and the caller decides what it means.
248
+ */
249
+ async headObject(key) {
250
+ return cloudflareRequest(`R2 head object '${key}'`, async () => {
251
+ try {
252
+ const response = await this.getS3Client().send(new HeadObjectCommand({
253
+ Bucket: this.bucketName,
254
+ Key: key
255
+ }));
256
+ return decodeResponse(R2ObjectHead, {
257
+ size: response.ContentLength,
258
+ etag: response.ETag,
259
+ contentType: response.ContentType,
260
+ uploaded: response.LastModified
261
+ }, `R2 head object '${key}'`);
262
+ } catch (error) {
263
+ if (isS3NotFound(error)) return null;
264
+ throw error;
265
+ }
266
+ });
267
+ }
268
+ /**
269
+ * Every multipart upload started in this bucket and never completed or aborted.
270
+ *
271
+ * Abandoned uploads are invisible and not free: R2 stores and bills the parts, and a bucket holding one
272
+ * cannot be deleted at all. Nothing else can find them — `listObjects` does not show an upload that never
273
+ * assembled, and `listParts` needs the `uploadId` you are trying to discover. So this is the only route to
274
+ * reclaiming a bucket, and the only way an orphan sweep can see the cost it is meant to reclaim.
275
+ *
276
+ * Fully drained rather than cursor-paged: a caller wants all of them, and the count is bounded by how many
277
+ * uploads were abandoned rather than by how much data the bucket holds.
278
+ */
279
+ async listMultipartUploads(prefix) {
280
+ return cloudflareRequest("R2 list multipart uploads", async () => {
281
+ const found = [];
282
+ let keyMarker;
283
+ let uploadIdMarker;
284
+ do {
285
+ const response = await this.getS3Client().send(new ListMultipartUploadsCommand({
286
+ Bucket: this.bucketName,
287
+ Prefix: prefix,
288
+ KeyMarker: keyMarker,
289
+ UploadIdMarker: uploadIdMarker
290
+ }));
291
+ for (const upload of response.Uploads ?? []) {
292
+ if (!upload.Key || !upload.UploadId) continue;
293
+ found.push(decodeResponse(R2PendingUpload, {
294
+ key: upload.Key,
295
+ uploadId: upload.UploadId,
296
+ initiated: upload.Initiated
297
+ }, "R2 pending multipart upload"));
298
+ }
299
+ keyMarker = response.IsTruncated ? response.NextKeyMarker : void 0;
300
+ uploadIdMarker = response.IsTruncated ? response.NextUploadIdMarker : void 0;
301
+ } while (keyMarker || uploadIdMarker);
302
+ return found;
303
+ });
304
+ }
305
+ /**
306
+ * List one page of object keys. Pagination is caller-driven: pass the returned `cursor` back to get
307
+ * the next page, and stop when it is absent. Unlike `listParts` a bucket has no bounded size, so
308
+ * draining it here would be an unbounded loop with no way for the caller to stop early.
309
+ */
310
+ async listObjects(options = {}) {
311
+ return cloudflareRequest("R2 list objects", async () => {
312
+ const response = await this.getS3Client().send(new ListObjectsV2Command({
313
+ Bucket: this.bucketName,
314
+ Prefix: options.prefix,
315
+ ContinuationToken: options.cursor,
316
+ MaxKeys: options.maxKeys
317
+ }));
318
+ const keys = (response.Contents ?? []).map((entry) => entry.Key).filter((key) => Boolean(key));
319
+ return decodeResponse(R2ObjectListing, {
320
+ keys,
321
+ cursor: response.IsTruncated ? response.NextContinuationToken : void 0
322
+ }, "R2 list objects");
323
+ });
324
+ }
325
+ /**
326
+ * Copy an object within this bucket, server-side — the bytes never leave R2.
327
+ *
328
+ * `CopySource` is `/<bucket>/<key>` with the key percent-encoded per path segment. The SDK does not
329
+ * encode it, and a key containing a space or `?` produces a malformed source header otherwise;
330
+ * encoding whole would destroy the `/` separators a nested key depends on.
331
+ */
332
+ async copyObject(sourceKey, destKey) {
333
+ await cloudflareRequest(`R2 copy '${sourceKey}' to '${destKey}'`, async () => {
334
+ const source = `/${this.bucketName}/${sourceKey.split("/").map(encodeURIComponent).join("/")}`;
335
+ await this.getS3Client().send(new CopyObjectCommand({
336
+ Bucket: this.bucketName,
337
+ Key: destKey,
338
+ CopySource: source
339
+ }));
340
+ });
341
+ }
342
+ /**
343
+ * Delete one object. Idempotent by protocol — S3 answers a delete of an absent key with a success,
344
+ * so teardown and orphan sweeps can re-run safely.
345
+ */
346
+ async deleteObject(key) {
347
+ await cloudflareRequest(`R2 delete object '${key}'`, async () => {
348
+ await this.getS3Client().send(new DeleteObjectCommand({
349
+ Bucket: this.bucketName,
350
+ Key: key
351
+ }));
352
+ });
353
+ }
354
+ /**
355
+ * Delete every object in this bucket and abort every multipart upload still in flight.
356
+ *
357
+ * **R2 refuses to delete a bucket that is not empty**, so this is the step that has to run before
358
+ * `CloudflareR2Provisioner.deleteBucket` — and it is the reason a teardown needs the S3 key pair at
359
+ * all: the CF API has no object plane, so nothing but this protocol can empty a bucket.
360
+ *
361
+ * Two different things hold a bucket, and only one of them is visible. Stored objects show up in a
362
+ * listing; the parts of an upload that was started and never completed or aborted do not, and they
363
+ * block the delete just the same. So the aborts go first: draining the keys of a bucket that still
364
+ * carries a dangling upload leaves it empty-looking and undeletable, which is the confusing failure.
365
+ *
366
+ * **Destructive and unrecoverable**, and it does not ask — the caller owns the confirmation. Idempotent:
367
+ * an already-empty bucket is a no-op reporting zeroes, so a retried teardown is safe.
368
+ */
369
+ async emptyBucket() {
370
+ let uploadsAborted = 0;
371
+ for (const pending of await this.listMultipartUploads()) {
372
+ await this.abortMultipartUpload(pending.key, pending.uploadId);
373
+ uploadsAborted += 1;
374
+ }
375
+ let objectsDeleted = 0;
376
+ let cursor;
377
+ do {
378
+ const page = await this.listObjects({ cursor });
379
+ for (const key of page.keys) {
380
+ await this.deleteObject(key);
381
+ objectsDeleted += 1;
382
+ }
383
+ cursor = page.cursor;
384
+ } while (cursor);
385
+ return {
386
+ objectsDeleted,
387
+ uploadsAborted
388
+ };
389
+ }
390
+ getServiceType() {
391
+ return "Cloudflare R2";
392
+ }
393
+ /**
394
+ * Prove access by reading the bucket record over the CF API (token + account + bucket existence),
395
+ * not merely that an S3 client could be built. Never throws.
396
+ */
397
+ async validateServiceAccess() {
398
+ try {
399
+ await this.getClient().r2.buckets.get(this.bucketName, { account_id: this.accountId });
400
+ return true;
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
405
+ };
406
+ //#endregion
407
+ export { CloudflareR2Manager, R2ObjectHead, R2ObjectListing, R2PendingUpload, R2UploadedPart };