Skip to content
File

Blob: src/cloudflare/internal/images-api.ts

typescript399 lines
1// Copyright (c) 2024 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4import { StreamableFormData } from 'cloudflare-internal:streaming-forms';
5import {
6 createBase64DecoderTransformStream,
7 createBase64EncoderTransformStream,
8} from 'cloudflare-internal:streaming-base64';
9import { withSpan, type Span } from 'cloudflare-internal:tracing-helpers';
10 
11type Fetcher = {
12 fetch: typeof fetch;
13};
14 
15type TargetedTransform = ImageTransform & {
16 imageIndex: number;
17};
18 
19// Draw image drawImageIndex on image targetImageIndex
20type DrawCommand = ImageDrawOptions & {
21 drawImageIndex: number;
22 targetImageIndex: number;
23};
24 
25type RawInfoResponse =
26 | { format: 'image/svg+xml' }
27 | {
28 format: string;
29 file_size: number;
30 width: number;
31 height: number;
32 };
33 
34class TransformationResultImpl implements ImageTransformationResult {
35 readonly #bindingsResponse: Response;
36 
37 constructor(bindingsResponse: Response) {
38 this.#bindingsResponse = bindingsResponse;
39 }
40 
41 contentType(): string {
42 const contentType = this.#bindingsResponse.headers.get('content-type');
43 if (!contentType) {
44 throw new ImagesErrorImpl(
45 'IMAGES_TRANSFORM_ERROR 9523: No content-type on bindings response',
46 9523
47 );
48 }
49 
50 return contentType;
51 }
52 
53 image(
54 options?: ImageTransformationOutputOptions
55 ): ReadableStream<Uint8Array> {
56 const stream = this.#bindingsResponse.body || new Blob().stream();
57 
58 return options?.encoding === 'base64'
59 ? stream.pipeThrough(createBase64EncoderTransformStream())
60 : stream;
61 }
62 
63 response(): Response {
64 return new Response(this.image(), {
65 headers: {
66 'content-type': this.contentType(),
67 },
68 });
69 }
70}
71 
72class DrawTransformer {
73 readonly child: ImageTransformerImpl;
74 readonly options: ImageDrawOptions;
75 constructor(child: ImageTransformerImpl, options: ImageDrawOptions) {
76 this.child = child;
77 this.options = options;
78 }
79}
80 
81class ImageTransformerImpl implements ImageTransformer {
82 readonly #fetcher: Fetcher;
83 readonly #stream: ReadableStream<Uint8Array>;
84 
85 #transforms: (ImageTransform | DrawTransformer)[];
86 #consumed: boolean;
87 
88 constructor(fetcher: Fetcher, stream: ReadableStream<Uint8Array>) {
89 this.#fetcher = fetcher;
90 this.#stream = stream;
91 this.#transforms = [];
92 this.#consumed = false;
93 }
94 
95 transform(transform: ImageTransform): this {
96 this.#transforms.push(transform);
97 return this;
98 }
99 
100 draw(
101 image: ReadableStream<Uint8Array> | ImageTransformer,
102 options: ImageDrawOptions = {}
103 ): this {
104 if (isTransformer(image)) {
105 image.#consume();
106 this.#transforms.push(new DrawTransformer(image, options));
107 } else {
108 this.#transforms.push(
109 new DrawTransformer(
110 new ImageTransformerImpl(
111 this.#fetcher,
112 image as ReadableStream<Uint8Array>
113 ),
114 options
115 )
116 );
117 }
118 
119 return this;
120 }
121 
122 async output(
123 options: ImageOutputOptions
124 ): Promise<ImageTransformationResult> {
125 return await withSpan('images_output', async (span) => {
126 span.setAttribute('cloudflare.binding.type', 'Images');
127 const formData = new StreamableFormData();
128 
129 this.#consume();
130 formData.append('image', this.#stream, { type: 'file' });
131 
132 this.#serializeTransforms(formData, span);
133 
134 span.setAttribute('cloudflare.images.options.format', options.format);
135 formData.append('output_format', options.format);
136 
137 if (options.quality !== undefined) {
138 span.setAttribute('cloudflare.images.options.quality', options.quality);
139 formData.append('output_quality', options.quality.toString());
140 }
141 
142 if (options.background !== undefined) {
143 span.setAttribute(
144 'cloudflare.images.options.background',
145 options.background
146 );
147 formData.append('background', options.background);
148 }
149 
150 if (options.anim !== undefined) {
151 span.setAttribute('cloudflare.images.options.anim', options.anim);
152 formData.append('anim', options.anim.toString());
153 }
154 
155 const response = await this.#fetcher.fetch(
156 'https://js.images.cloudflare.com/transform',
157 {
158 method: 'POST',
159 headers: {
160 'content-type': formData.contentType(),
161 },
162 body: formData.stream(),
163 }
164 );
165 
166 await throwErrorIfErrorResponse('TRANSFORM', response, span);
167 
168 return new TransformationResultImpl(response);
169 });
170 }
171 
172 #consume(): void {
173 if (this.#consumed) {
174 throw new ImagesErrorImpl(
175 'IMAGES_TRANSFORM_ERROR 9525: ImageTransformer consumed; you may only call .output() or draw a transformer once',
176 9525
177 );
178 }
179 
180 this.#consumed = true;
181 }
182 
183 #serializeTransforms(formData: StreamableFormData, span: Span): void {
184 const transforms: (TargetedTransform | DrawCommand)[] = [];
185 
186 // image 0 is the canvas, so the first draw_image has index 1
187 let drawImageIndex = 1;
188 function appendDrawImage(stream: ReadableStream): number {
189 formData.append('draw_image', stream, { type: 'file' });
190 return drawImageIndex++;
191 }
192 
193 function walkTransforms(
194 targetImageIndex: number,
195 imageTransforms: (ImageTransform | DrawTransformer)[]
196 ): void {
197 for (const transform of imageTransforms) {
198 if (!isDrawTransformer(transform)) {
199 // Simple transformation - we just have to tell the backend to run it
200 // against this image
201 transforms.push({
202 imageIndex: targetImageIndex,
203 ...transform,
204 });
205 } else {
206 // Drawn child image
207 // Set the input for the drawn image on the form
208 const drawImageIndex = appendDrawImage(transform.child.#stream);
209 
210 // Tell the backend to run any transforms (possibly involving more draws)
211 // required to build this child
212 walkTransforms(drawImageIndex, transform.child.#transforms);
213 
214 // Draw the child image on to the canvas
215 transforms.push({
216 drawImageIndex: drawImageIndex,
217 targetImageIndex: targetImageIndex,
218 ...transform.options,
219 });
220 }
221 }
222 }
223 
224 walkTransforms(0, this.#transforms);
225 
226 // The transforms are a set of operations which are applied to the image in order.
227 // Attaching an attribute as JSON is a little odd, but I'm not sure if there is
228 // a better way to do this.
229 if (transforms.length > 0) {
230 span.setAttribute(
231 'cloudflare.images.options.transforms',
232 JSON.stringify(transforms)
233 );
234 }
235 formData.append('transforms', JSON.stringify(transforms));
236 }
237}
238 
239function isTransformer(input: unknown): input is ImageTransformerImpl {
240 return input instanceof ImageTransformerImpl;
241}
242 
243function isDrawTransformer(input: unknown): input is DrawTransformer {
244 return input instanceof DrawTransformer;
245}
246 
247interface ServiceEntrypointStub {
248 image(imageId: string): ImageHandle;
249 upload(
250 image: ReadableStream<Uint8Array> | ArrayBuffer,
251 options?: ImageUploadOptions
252 ): Promise<ImageMetadata>;
253 list(options?: ImageListOptions): Promise<ImageList>;
254}
255 
256class HostedImagesBindingImpl implements HostedImagesBinding {
257 readonly #fetcher: ServiceEntrypointStub;
258 
259 constructor(fetcher: ServiceEntrypointStub) {
260 this.#fetcher = fetcher;
261 }
262 
263 image(imageId: string): ImageHandle {
264 return this.#fetcher.image(imageId);
265 }
266 
267 async upload(
268 image: ReadableStream<Uint8Array> | ArrayBuffer,
269 options?: ImageUploadOptions
270 ): Promise<ImageMetadata> {
271 return this.#fetcher.upload(image, options);
272 }
273 
274 async list(options?: ImageListOptions): Promise<ImageList> {
275 return this.#fetcher.list(options);
276 }
277}
278 
279class ImagesBindingImpl implements ImagesBinding {
280 readonly #fetcher: Fetcher & ServiceEntrypointStub;
281 readonly #hosted: HostedImagesBinding;
282 
283 constructor(fetcher: Fetcher & ServiceEntrypointStub) {
284 this.#fetcher = fetcher;
285 this.#hosted = new HostedImagesBindingImpl(fetcher);
286 }
287 
288 get hosted(): HostedImagesBinding {
289 return this.#hosted;
290 }
291 
292 async info(
293 stream: ReadableStream<Uint8Array>,
294 options?: ImageInputOptions
295 ): Promise<ImageInfoResponse> {
296 return await withSpan('images_info', async (span) => {
297 span.setAttribute('cloudflare.binding.type', 'Images');
298 const body = new StreamableFormData();
299 
300 span.setAttribute(
301 'cloudflare.images.options.encoding',
302 options?.encoding ?? 'base64'
303 );
304 const decodedStream =
305 options?.encoding === 'base64'
306 ? stream.pipeThrough(createBase64DecoderTransformStream())
307 : stream;
308 
309 body.append('image', decodedStream, { type: 'file' });
310 
311 const response = await this.#fetcher.fetch(
312 'https://js.images.cloudflare.com/info',
313 {
314 method: 'POST',
315 headers: {
316 'content-type': body.contentType(),
317 },
318 body: body.stream(),
319 }
320 );
321 
322 await throwErrorIfErrorResponse('INFO', response, span);
323 
324 const r = (await response.json()) as RawInfoResponse;
325 
326 span.setAttribute('cloudflare.images.result.format', r.format);
327 
328 if ('file_size' in r) {
329 const ret = {
330 fileSize: r.file_size,
331 width: r.width,
332 height: r.height,
333 format: r.format,
334 };
335 span.setAttribute('cloudflare.images.result.file_size', ret.fileSize);
336 span.setAttribute('cloudflare.images.result.width', ret.width);
337 span.setAttribute('cloudflare.images.result.height', ret.height);
338 return ret;
339 }
340 
341 return r;
342 });
343 }
344 
345 input(
346 stream: ReadableStream<Uint8Array>,
347 options?: ImageInputOptions
348 ): ImageTransformer {
349 const decodedStream =
350 options?.encoding === 'base64'
351 ? stream.pipeThrough(createBase64DecoderTransformStream())
352 : stream;
353 
354 return new ImageTransformerImpl(this.#fetcher, decodedStream);
355 }
356}
357 
358class ImagesErrorImpl extends Error implements ImagesError {
359 readonly code: number;
360 constructor(message: string, code: number) {
361 super(message);
362 this.code = code;
363 }
364}
365 
366async function throwErrorIfErrorResponse(
367 operation: string,
368 response: Response,
369 span: Span
370): Promise<void> {
371 const statusHeader = response.headers.get('cf-images-binding') || '';
372 
373 const match = /err=(\d+)/.exec(statusHeader);
374 
375 if (match && match[1]) {
376 const errorMessage = await response.text();
377 span.setAttribute('cloudflare.images.error.code', match[1]);
378 span.setAttribute('error.type', errorMessage);
379 throw new ImagesErrorImpl(
380 `IMAGES_${operation}_${errorMessage}`.trim(),
381 Number.parseInt(match[1])
382 );
383 }
384 
385 if (response.status > 399) {
386 const errorMessage = await response.text();
387 span.setAttribute('cloudflare.images.error.code', '9523');
388 span.setAttribute('error.type', errorMessage);
389 throw new ImagesErrorImpl(
390 `Unexpected error response ${response.status}: ${errorMessage.trim()}`,
391 9523
392 );
393 }
394}
395 
396export default function makeBinding(env: { fetcher: Fetcher }): ImagesBinding {
397 return new ImagesBindingImpl(env.fetcher as Fetcher & ServiceEntrypointStub);
398}