import * as Effect from "effect/Effect";
import type * as Redacted from "effect/Redacted";
import * as Stream from "effect/Stream";
import type * as HttpClient from "effect/unstable/http/HttpClient";
import { Self } from "../../Self.ts";
import type { RuntimeContext } from "../../RuntimeContext.ts";
import { AccountApiToken } from "../ApiToken/AccountApiToken.ts";
import type { PermissionGroupRef } from "../ApiToken/Common.ts";
import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
import type { Credentials } from "../Credentials.ts";
import type { Bucket } from "./Bucket.ts";
import { R2Error, type R2Object } from "./BucketTypes.ts";
/**
* Injectable auth used by the R2 HTTP client builders. Both the token-scoped
* `*Http` layers and the current-credentials `*Local` layers build one of
* these so they can share the exact same client implementation.
*/
export interface R2Auth {
/** Provide credentials + HTTP client to a raw distilled R2 op. */
authorize: (
eff: Effect.Effect,
) => Effect.Effect;
/** Resolve the Cloudflare account id. */
accountId: Effect.Effect;
}
export const makeHttpBucketBinding = (options: {
permissionGroups: PermissionGroup[];
makeClient: (
token: HttpToken,
bucketName: Effect.Effect,
jurisdiction: Effect.Effect,
) => Client;
}) =>
Effect.gen(function* () {
const Token = yield* AccountApiToken;
const self = yield* Self;
const env = yield* CloudflareEnvironment;
return Effect.fn(function* (bucket: Bucket) {
const { accountId } = yield* env;
const token = yield* Token(`${self.LogicalId}Token`);
if (!globalThis.__ALCHEMY_RUNTIME__) {
yield* token.bind`${bucket.LogicalId}`({
policies: [
{
effect: "allow",
permissionGroups: options.permissionGroups,
resources: {
[`com.cloudflare.api.account.${accountId}`]: "*",
},
},
],
});
}
const bound = {
value: yield* token.value,
accountId: yield* token.accountId,
} satisfies HttpToken;
const bucketName = yield* bucket.bucketName;
const jurisdiction = yield* bucket.jurisdiction;
return options.makeClient(bound, bucketName, jurisdiction);
});
});
export interface HttpScope {
accountId: string;
bucketName: string;
cfR2Jurisdiction: string | undefined;
}
export interface HttpToken {
value: Effect.Effect>;
accountId: Effect.Effect;
}
const R2_HTTP_PERMISSION_GROUPS: PermissionGroupRef[] = [
"Workers R2 Storage Read",
"Workers R2 Storage Write",
];
type PermissionGroup = (typeof R2_HTTP_PERMISSION_GROUPS)[number];
/** Resolve the account, bucket, and jurisdiction once per operation. */
export const makeR2HttpScope = (
accountId: Effect.Effect,
bucketName: Effect.Effect,
jurisdiction: Effect.Effect,
): Effect.Effect =>
Effect.gen(function* () {
const accountId_ = yield* accountId;
const bucket = yield* bucketName;
const j = yield* jurisdiction;
return {
accountId: accountId_,
bucketName: bucket,
cfR2Jurisdiction: j === "default" ? undefined : j,
};
});
/**
* Bind the token's `value` (as `secret_text`) and `accountId` (as `plain_text`)
* into the Worker so they can be read at runtime.
*/
export const toR2Error = (error: unknown): R2Error =>
new R2Error({
message:
typeof error === "object" && error !== null && "message" in error
? String((error as { message: unknown }).message)
: "Unknown R2 error",
cause: error instanceof Error ? error : new Error(String(error)),
});
const stripQuotes = (etag: string | undefined): string | undefined =>
etag === undefined ? undefined : etag.replace(/^"|"$/g, "");
export interface HttpMetadata {
contentType?: string;
contentEncoding?: string;
contentDisposition?: string;
contentLanguage?: string;
cacheControl?: string;
cacheExpiry?: Date;
}
/** Normalize the caller's `httpMetadata` option (object or `Headers`). */
export const readHttpMetadata = (
options: { httpMetadata?: unknown } | undefined,
): HttpMetadata | undefined => {
const meta = options?.httpMetadata;
if (!meta) return undefined;
if (meta instanceof Headers) {
return {
contentType: meta.get("content-type") ?? undefined,
contentEncoding: meta.get("content-encoding") ?? undefined,
contentDisposition: meta.get("content-disposition") ?? undefined,
contentLanguage: meta.get("content-language") ?? undefined,
cacheControl: meta.get("cache-control") ?? undefined,
};
}
return meta as HttpMetadata;
};
/** Write an object's HTTP metadata onto a `Headers` instance. */
const applyHttpMetadata = (headers: Headers, meta: HttpMetadata): void => {
if (meta.contentType) headers.set("content-type", meta.contentType);
if (meta.contentEncoding)
headers.set("content-encoding", meta.contentEncoding);
if (meta.contentDisposition)
headers.set("content-disposition", meta.contentDisposition);
if (meta.contentLanguage)
headers.set("content-language", meta.contentLanguage);
if (meta.cacheControl) headers.set("cache-control", meta.cacheControl);
};
export const baseObject = (
key: string,
meta: HttpMetadata,
attrs: {
size?: number;
etag?: string;
uploaded?: Date;
storageClass?: string;
customMetadata?: Record;
},
): R2Object =>
({
key,
version: "",
size: attrs.size ?? 0,
etag: stripQuotes(attrs.etag) ?? "",
httpEtag: attrs.etag ?? "",
checksums: {},
uploaded: attrs.uploaded ?? new Date(0),
httpMetadata: meta,
customMetadata: attrs.customMetadata ?? {},
range: undefined,
storageClass: attrs.storageClass ?? "Standard",
writeHttpMetadata: (headers: Headers) =>
Effect.sync(() => applyHttpMetadata(headers, meta)),
}) as unknown as R2Object;
/** Collect a put `value` into a body accepted by the R2 HTTP API. */
export const toBody = (
value:
| ReadableStream
| ArrayBuffer
| ArrayBufferView
| string
| null
| Blob
| Stream.Stream,
): Effect.Effect<
{ body: Blob | Uint8Array | ArrayBuffer | string; contentLength?: number },
R2Error
> =>
Effect.gen(function* () {
if (value === null) return { body: new Uint8Array(0), contentLength: 0 };
if (typeof value === "string") return { body: value };
if (value instanceof Blob)
return { body: value, contentLength: value.size };
if (value instanceof ArrayBuffer)
return { body: value, contentLength: value.byteLength };
if (Stream.isStream(value) || value instanceof ReadableStream) {
const readable = Stream.isStream(value)
? Stream.toReadableStream(value)
: value;
const buffer = yield* Effect.tryPromise({
try: () => new Response(readable as any).arrayBuffer(),
catch: toR2Error,
});
return { body: new Uint8Array(buffer), contentLength: buffer.byteLength };
}
const view = value as ArrayBufferView;
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
return { body: bytes, contentLength: bytes.byteLength };
});