1import * as Effect from "effect/Effect";
2import type * as Redacted from "effect/Redacted";
3import * as Stream from "effect/Stream";
4import type * as HttpClient from "effect/http/HttpClient";
5import { Self } from "../../Self.ts";
6import type { RuntimeContext } from "../../RuntimeContext.ts";
7import { AccountApiToken } from "../ApiToken/AccountApiToken.ts";
8import type { PermissionGroupRef } from "../ApiToken/Common.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Credentials } from "../Credentials.ts";
11import type { Bucket } from "./Bucket.ts";
12import { R2Error, type R2Object } from "./BucketTypes.ts";
13
14/**
15 * Injectable auth used by the R2 HTTP client builders. Both the token-scoped
16 * `*Http` layers and the current-credentials `*Local` layers build one of
17 * these so they can share the exact same client implementation.
18 */
19export interface R2Auth {
20 /** Provide credentials + HTTP client to a raw distilled R2 op. */
21 authorize: <A, E>(
22 eff: Effect.Effect<A, E, Credentials | HttpClient.HttpClient>,
23 ) => Effect.Effect<A, E, RuntimeContext>;
24 /** Resolve the Cloudflare account id. */
25 accountId: Effect.Effect<string>;
26}
27
28export const makeHttpBucketBinding = <Client>(options: {
29 permissionGroups: PermissionGroup[];
30 makeClient: (
31 token: HttpToken,
32 bucketName: Effect.Effect<string>,
33 jurisdiction: Effect.Effect<string>,
34 ) => Client;
35}) =>
36 Effect.gen(function* () {
37 const Token = yield* AccountApiToken;
38 const self = yield* Self;
39 const env = yield* CloudflareEnvironment;
40
41 return Effect.fn(function* (bucket: Bucket) {
42 const { accountId } = yield* env;
43 const token = yield* Token(`${self.LogicalId}Token`);
44 if (!globalThis.__ALCHEMY_RUNTIME__) {
45 yield* token.bind`${bucket.LogicalId}`({
46 policies: [
47 {
48 effect: "allow",
49 permissionGroups: options.permissionGroups,
50 resources: {
51 [`com.cloudflare.api.account.${accountId}`]: "*",
52 },
53 },
54 ],
55 });
56 }
57 const bound = {
58 value: yield* token.value,
59 accountId: yield* token.accountId,
60 } satisfies HttpToken;
61 const bucketName = yield* bucket.bucketName;
62 const jurisdiction = yield* bucket.jurisdiction;
63 return options.makeClient(bound, bucketName, jurisdiction);
64 });
65 });
66
67export interface HttpScope {
68 accountId: string;
69 bucketName: string;
70 cfR2Jurisdiction: string | undefined;
71}
72export interface HttpToken {
73 value: Effect.Effect<Redacted.Redacted<string>>;
74 accountId: Effect.Effect<string>;
75}
76
77const R2_HTTP_PERMISSION_GROUPS: PermissionGroupRef[] = [
78 "Workers R2 Storage Read",
79 "Workers R2 Storage Write",
80];
81
82type PermissionGroup = (typeof R2_HTTP_PERMISSION_GROUPS)[number];
83
84/** Resolve the account, bucket, and jurisdiction once per operation. */
85export const makeR2HttpScope = (
86 accountId: Effect.Effect<string>,
87 bucketName: Effect.Effect<string>,
88 jurisdiction: Effect.Effect<string>,
89): Effect.Effect<HttpScope> =>
90 Effect.gen(function* () {
91 const accountId_ = yield* accountId;
92 const bucket = yield* bucketName;
93 const j = yield* jurisdiction;
94 return {
95 accountId: accountId_,
96 bucketName: bucket,
97 cfR2Jurisdiction: j === "default" ? undefined : j,
98 };
99 });
100
101/**
102 * Bind the token's `value` (as `secret_text`) and `accountId` (as `plain_text`)
103 * into the Worker so they can be read at runtime.
104 */
105
106export const toR2Error = (error: unknown): R2Error =>
107 new R2Error({
108 message:
109 typeof error === "object" && error !== null && "message" in error
110 ? String((error as { message: unknown }).message)
111 : "Unknown R2 error",
112 cause: error instanceof Error ? error : new Error(String(error)),
113 });
114
115const stripQuotes = (etag: string | undefined): string | undefined =>
116 etag === undefined ? undefined : etag.replace(/^"|"$/g, "");
117
118export interface HttpMetadata {
119 contentType?: string;
120 contentEncoding?: string;
121 contentDisposition?: string;
122 contentLanguage?: string;
123 cacheControl?: string;
124 cacheExpiry?: Date;
125}
126
127/** Normalize the caller's `httpMetadata` option (object or `Headers`). */
128export const readHttpMetadata = (
129 options: { httpMetadata?: unknown } | undefined,
130): HttpMetadata | undefined => {
131 const meta = options?.httpMetadata;
132 if (!meta) return undefined;
133 if (meta instanceof Headers) {
134 return {
135 contentType: meta.get("content-type") ?? undefined,
136 contentEncoding: meta.get("content-encoding") ?? undefined,
137 contentDisposition: meta.get("content-disposition") ?? undefined,
138 contentLanguage: meta.get("content-language") ?? undefined,
139 cacheControl: meta.get("cache-control") ?? undefined,
140 };
141 }
142 return meta as HttpMetadata;
143};
144
145/** Write an object's HTTP metadata onto a `Headers` instance. */
146const applyHttpMetadata = (headers: Headers, meta: HttpMetadata): void => {
147 if (meta.contentType) headers.set("content-type", meta.contentType);
148 if (meta.contentEncoding)
149 headers.set("content-encoding", meta.contentEncoding);
150 if (meta.contentDisposition)
151 headers.set("content-disposition", meta.contentDisposition);
152 if (meta.contentLanguage)
153 headers.set("content-language", meta.contentLanguage);
154 if (meta.cacheControl) headers.set("cache-control", meta.cacheControl);
155};
156
157export const baseObject = (
158 key: string,
159 meta: HttpMetadata,
160 attrs: {
161 size?: number;
162 etag?: string;
163 uploaded?: Date;
164 storageClass?: string;
165 customMetadata?: Record<string, string>;
166 },
167): R2Object =>
168 ({
169 key,
170 version: "",
171 size: attrs.size ?? 0,
172 etag: stripQuotes(attrs.etag) ?? "",
173 httpEtag: attrs.etag ?? "",
174 checksums: {},
175 uploaded: attrs.uploaded ?? new Date(0),
176 httpMetadata: meta,
177 customMetadata: attrs.customMetadata ?? {},
178 range: undefined,
179 storageClass: attrs.storageClass ?? "Standard",
180 writeHttpMetadata: (headers: Headers) =>
181 Effect.sync(() => applyHttpMetadata(headers, meta)),
182 }) as unknown as R2Object;
183
184/** Collect a put `value` into a body accepted by the R2 HTTP API. */
185export const toBody = (
186 value:
187 | ReadableStream
188 | ArrayBuffer
189 | ArrayBufferView
190 | string
191 | null
192 | Blob
193 | Stream.Stream<Uint8Array, unknown>,
194): Effect.Effect<
195 { body: Blob | Uint8Array | ArrayBuffer | string; contentLength?: number },
196 R2Error
197> =>
198 Effect.gen(function* () {
199 if (value === null) return { body: new Uint8Array(0), contentLength: 0 };
200 if (typeof value === "string") return { body: value };
201 if (value instanceof Blob)
202 return { body: value, contentLength: value.size };
203 if (value instanceof ArrayBuffer)
204 return { body: value, contentLength: value.byteLength };
205 if (Stream.isStream(value) || value instanceof ReadableStream) {
206 const readable = Stream.isStream(value)
207 ? Stream.toReadableStream(value)
208 : value;
209 const buffer = yield* Effect.tryPromise({
210 try: () => new Response(readable as any).arrayBuffer(),
211 catch: toR2Error,
212 });
213 return { body: new Uint8Array(buffer), contentLength: buffer.byteLength };
214 }
215 const view = value as ArrayBufferView;
216 const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
217 return { body: bytes, contentLength: bytes.byteLength };
218 });