import type { HostedImagesBinding as CloudflareHostedImagesBinding, ImageDrawOptions as CloudflareImageDrawOptions, ImageHandle as CloudflareImageHandle, ImageInfoResponse as CloudflareImageInfoResponse, ImageInputOptions as CloudflareImageInputOptions, ImageList as CloudflareImageList, ImageListOptions as CloudflareImageListOptions, ImageMetadata as CloudflareImageMetadata, ImageOutputOptions as CloudflareImageOutputOptions, ImageTransform as CloudflareImageTransform, ImageTransformationOutputOptions as CloudflareImageTransformationOutputOptions, ImageTransformationResult as CloudflareImageTransformationResult, ImageTransformer as CloudflareImageTransformer, ImageUpdateOptions as CloudflareImageUpdateOptions, ImageUploadOptions as CloudflareImageUploadOptions, Response as CloudflareResponse, } from "@cloudflare/workers-types"; import { Context, Data, Effect, Option, type Layer } from "effect"; import * as Binding from "./Binding"; import type { WorkerEnvironment } from "./Environment"; const TypeId = "effect-cf/Images/Steps" as const; const expectedImagesBinding = "Images binding with info() and input()"; /** Error raised when a Cloudflare Images operation fails. */ export class ImagesOperationError extends Data.TaggedError("ImagesOperationError")<{ readonly binding: string; readonly operation: string; readonly cause: unknown; }> {} /** Typed Cloudflare Images binding definition. */ export interface ImagesDefinition { /** Binding name as configured in `wrangler.jsonc`. */ readonly binding: string; } export type ImageInfoResponse = CloudflareImageInfoResponse; export type ImageTransform = CloudflareImageTransform; export type ImageDrawOptions = CloudflareImageDrawOptions; export type ImageInputOptions = CloudflareImageInputOptions; export type ImageOutputOptions = CloudflareImageOutputOptions; export type ImageTransformationOutputOptions = CloudflareImageTransformationOutputOptions; export type ImageTransformationResult = CloudflareImageTransformationResult; export type ImageUploadOptions = CloudflareImageUploadOptions; export type ImageUpdateOptions = CloudflareImageUpdateOptions; export type ImageListOptions = CloudflareImageListOptions; export type ImageList = CloudflareImageList; export type ImageMetadata = CloudflareImageMetadata; export type ImageInputValue = ReadableStream | ArrayBuffer; export type ImageUploadValue = ReadableStream | ArrayBuffer; export interface DrawStepOptions { readonly image: ReadableStream | CloudflareImageTransformer; readonly options?: ImageDrawOptions; } export type Step = Data.TaggedEnum<{ readonly Transform: { readonly transform: ImageTransform; }; readonly Draw: DrawStepOptions; }>; export interface Steps { readonly [TypeId]: typeof TypeId; readonly steps: ReadonlyArray; } export interface ProcessOptions { readonly stream: ImageInputValue; readonly inputOptions?: ImageInputOptions; readonly outputOptions: ImageOutputOptions; } export interface ImagesTransformationResultClient { readonly raw: CloudflareImageTransformationResult; readonly response: Effect.Effect; readonly contentType: Effect.Effect; readonly image: ( options?: ImageTransformationOutputOptions, ) => Effect.Effect, ImagesOperationError>; } export interface ImageHandleClient { readonly raw: CloudflareImageHandle; readonly details: Effect.Effect, ImagesOperationError>; readonly bytes: Effect.Effect>, ImagesOperationError>; readonly update: ( options: ImageUpdateOptions, ) => Effect.Effect; readonly delete: Effect.Effect; } export interface HostedImagesClient { readonly image: (imageId: string) => ImageHandleClient; readonly upload: ( image: ImageUploadValue, options?: ImageUploadOptions, ) => Effect.Effect; readonly list: (options?: ImageListOptions) => Effect.Effect; readonly unsafeRaw: Effect.Effect; } export interface ImagesRuntimeBinding { readonly info: ( image: ImageInputValue, options?: ImageInputOptions, ) => Promise; readonly input: ( image: ImageInputValue, options?: ImageInputOptions, ) => CloudflareImageTransformer; readonly hosted?: CloudflareHostedImagesBinding; } export interface ImagesClient { readonly info: ( image: ImageInputValue, options?: ImageInputOptions, ) => Effect.Effect; readonly input: ( image: ImageInputValue, options?: ImageInputOptions, ) => Effect.Effect; readonly process: ( steps: Steps, options: ProcessOptions, ) => Effect.Effect; readonly hosted: Option.Option; readonly unsafeRaw: Effect.Effect; readonly definition: ImagesDefinition; } declare const ImagesServiceTypeId: unique symbol; /** Nominal service marker for Images services created with {@link make}. */ export interface ImagesService { readonly [ImagesServiceTypeId]: { readonly id: Id; }; } export type LayerOptions = { readonly binding: string; }; export interface TagClass extends Context.ServiceClass< Self, Id, ImagesClient > { readonly id: Id; readonly layer: ( options: LayerOptions, ) => Layer.Layer< Self, Binding.BindingNotFoundError | Binding.BindingValidationError, WorkerEnvironment >; } const makeSteps = (steps: ReadonlyArray): Steps => ({ [TypeId]: TypeId, steps, }); /** Empty Images transformation pipeline. */ export const empty: Steps = makeSteps([]); export function transform(transform: ImageTransform): (steps: Steps) => Steps; export function transform(steps: Steps, transform: ImageTransform): Steps; export function transform( stepsOrTransform: Steps | ImageTransform, transformValue?: ImageTransform, ): Steps | ((steps: Steps) => Steps) { if (transformValue === undefined) { return (steps) => transform(steps, stepsOrTransform as ImageTransform); } const steps = stepsOrTransform as Steps; return makeSteps([ ...steps.steps, { _tag: "Transform", transform: transformValue, }, ]); } export function draw(draw: DrawStepOptions): (steps: Steps) => Steps; export function draw(steps: Steps, draw: DrawStepOptions): Steps; export function draw( stepsOrDraw: Steps | DrawStepOptions, drawValue?: DrawStepOptions, ): Steps | ((steps: Steps) => Steps) { if (drawValue === undefined) { return (steps) => draw(steps, stepsOrDraw as DrawStepOptions); } const steps = stepsOrDraw as Steps; return makeSteps([ ...steps.steps, { _tag: "Draw", ...drawValue, }, ]); } const imagesError = (binding: string, operation: string, cause: unknown) => new ImagesOperationError({ binding, operation, cause }); const tryImagesPromise = ( binding: string, operation: string, evaluate: () => Promise, ): Effect.Effect => Effect.tryPromise({ try: evaluate, catch: (cause) => imagesError(binding, operation, cause), }); const tryImagesSync = ( binding: string, operation: string, evaluate: () => A, ): Effect.Effect => Effect.try({ try: evaluate, catch: (cause) => imagesError(binding, operation, cause), }); const maybe = (value: A | null): Option.Option => value === null ? Option.none() : Option.some(value); const hasFunction = (value: object, key: string): boolean => typeof Reflect.get(value, key) === "function"; const isHostedImagesBinding = (value: unknown): value is CloudflareHostedImagesBinding => typeof value === "object" && value !== null && hasFunction(value, "image") && hasFunction(value, "upload") && hasFunction(value, "list"); export const isImagesBinding = (value: unknown): value is ImagesRuntimeBinding => typeof value === "object" && value !== null && hasFunction(value, "info") && hasFunction(value, "input"); const wrapResult = ( binding: string, result: CloudflareImageTransformationResult, ): ImagesTransformationResultClient => ({ raw: result, response: tryImagesSync(binding, "response", () => result.response()), contentType: tryImagesSync(binding, "contentType", () => result.contentType()), image: (options) => tryImagesSync(binding, "image", () => result.image(options)), }); const wrapHandle = (binding: string, handle: CloudflareImageHandle): ImageHandleClient => ({ raw: handle, details: tryImagesPromise(binding, "details", () => handle.details()).pipe(Effect.map(maybe)), bytes: tryImagesPromise(binding, "bytes", () => handle.bytes()).pipe(Effect.map(maybe)), update: (options) => tryImagesPromise(binding, "update", () => handle.update(options)), delete: tryImagesPromise(binding, "delete", () => handle.delete()), }); const wrapHosted = ( binding: string, hosted: CloudflareHostedImagesBinding, ): HostedImagesClient => ({ image: (imageId) => wrapHandle(binding, hosted.image(imageId)), upload: (image, options) => tryImagesPromise(binding, "upload", () => hosted.upload(image, options)), list: (options) => tryImagesPromise(binding, "list", () => hosted.list(options)), unsafeRaw: Effect.succeed(hosted), }); export const makeClient = (definition: ImagesDefinition) => (images: ImagesRuntimeBinding): ImagesClient => { const input = (image: ImageInputValue, options?: ImageInputOptions) => tryImagesSync(definition.binding, "input", () => images.input(image, options)); const process = (steps: Steps, options: ProcessOptions) => Effect.gen(function* () { let transformer = yield* input(options.stream, options.inputOptions); for (const step of steps.steps) { switch (step._tag) { case "Draw": { transformer = yield* tryImagesSync(definition.binding, "draw", () => transformer.draw(step.image, step.options), ); break; } case "Transform": { transformer = yield* tryImagesSync(definition.binding, "transform", () => transformer.transform(step.transform), ); break; } } } const result = yield* tryImagesPromise(definition.binding, "output", () => transformer.output(options.outputOptions), ); return wrapResult(definition.binding, result); }); return { definition, info: (image, options) => tryImagesPromise(definition.binding, "info", () => images.info(image, options)), input, process, hosted: isHostedImagesBinding(images.hosted) ? Option.some(wrapHosted(definition.binding, images.hosted)) : Option.none(), unsafeRaw: Effect.succeed(images), }; }; export const layer = ( tag: Context.Service, definition: ImagesDefinition, ) => Binding.layer(tag, definition.binding, isImagesBinding, makeClient(definition), { expected: expectedImagesBinding, }); export const make = (id: Id) => Tag>()(id); export const Tag = () => (id: Id) => { const tag = Context.Service()(id); const makeLayer = (definition: LayerOptions) => layer(tag, definition); return Object.assign(tag, { id, layer: makeLayer, }) as TagClass; };