From 3c7732b6a78c833c8a0ace77fea0ad4450f48f19 Mon Sep 17 00:00:00 2001 From: Matt Lewis Date: Tue, 13 Jan 2026 20:36:23 +0000 Subject: [PATCH 1/2] Add support for Cloudflare Images operations in the images binding --- src/cloudflare/internal/images-api.ts | 64 ++++- src/cloudflare/internal/images.d.ts | 90 +++++++ .../internal/test/images/BUILD.bazel | 1 + .../internal/test/images/images-api-test.js | 230 ++++++++++++++++++ .../test/images/images-api-test.wd-test | 15 +- .../test/images/images-upstream-mock.js | 150 +++++++++++- types/defines/images.d.ts | 87 +++++++ .../experimental/index.d.ts | 76 ++++++ .../generated-snapshot/experimental/index.ts | 76 ++++++ types/generated-snapshot/latest/index.d.ts | 76 ++++++ types/generated-snapshot/latest/index.ts | 76 ++++++ 11 files changed, 925 insertions(+), 16 deletions(-) diff --git a/src/cloudflare/internal/images-api.ts b/src/cloudflare/internal/images-api.ts index 97d649a48f4..4b374d3b76a 100644 --- a/src/cloudflare/internal/images-api.ts +++ b/src/cloudflare/internal/images-api.ts @@ -244,10 +244,22 @@ function isDrawTransformer(input: unknown): input is DrawTransformer { return input instanceof DrawTransformer; } +interface ServiceEntrypointStub { + get(imageId: string): Promise; + getImage(imageId: string): Promise | null>; + upload( + image: ReadableStream | ArrayBuffer, + options?: ImageUploadOptions + ): Promise; + update(imageId: string, options: ImageUpdateOptions): Promise; + delete(imageId: string): Promise; + list(options?: ImageListOptions): Promise; +} + class ImagesBindingImpl implements ImagesBinding { - readonly #fetcher: Fetcher; + readonly #fetcher: Fetcher & ServiceEntrypointStub; - constructor(fetcher: Fetcher) { + constructor(fetcher: Fetcher & ServiceEntrypointStub) { this.#fetcher = fetcher; } @@ -315,6 +327,52 @@ class ImagesBindingImpl implements ImagesBinding { return new ImageTransformerImpl(this.#fetcher, decodedStream); } + + async get(imageId: string): Promise { + return await this.#fetcher.get(imageId); + } + + async getImage(imageId: string): Promise | null> { + return await this.#fetcher.getImage(imageId); + } + + async upload( + image: ReadableStream | ArrayBuffer, + options?: ImageUploadOptions + ): Promise { + let processedImage: ReadableStream | ArrayBuffer = image; + + if (options?.encoding === 'base64') { + const stream = + image instanceof ReadableStream + ? image + : new ReadableStream({ + start(controller): void { + controller.enqueue(new Uint8Array(image)); + controller.close(); + }, + }); + + processedImage = stream.pipeThrough(createBase64DecoderTransformStream()); + } + + return await this.#fetcher.upload(processedImage, options); + } + + async update( + imageId: string, + options: ImageUpdateOptions + ): Promise { + return await this.#fetcher.update(imageId, options); + } + + async delete(imageId: string): Promise { + return await this.#fetcher.delete(imageId); + } + + async list(options?: ImageListOptions): Promise { + return await this.#fetcher.list(options || {}); + } } class ImagesErrorImpl extends Error implements ImagesError { @@ -356,5 +414,5 @@ async function throwErrorIfErrorResponse( } export default function makeBinding(env: { fetcher: Fetcher }): ImagesBinding { - return new ImagesBindingImpl(env.fetcher); + return new ImagesBindingImpl(env.fetcher as Fetcher & ServiceEntrypointStub); } diff --git a/src/cloudflare/internal/images.d.ts b/src/cloudflare/internal/images.d.ts index 95dccf45be2..9796c25b4a9 100644 --- a/src/cloudflare/internal/images.d.ts +++ b/src/cloudflare/internal/images.d.ts @@ -96,6 +96,46 @@ type ImageOutputOptions = { anim?: boolean; }; +interface ImageMetadata { + id: string; + filename?: string; + uploaded?: string; + requireSignedURLs: boolean; + meta?: Record; + variants: string[]; + draft?: boolean; + creator?: string; +} + +interface ImageUploadOptions { + id?: string; + filename?: string; + requireSignedURLs?: boolean; + metadata?: Record; + /** + * If 'base64', the input data will be decoded from base64 before processing + */ + encoding?: 'base64'; +} + +interface ImageUpdateOptions { + requireSignedURLs?: boolean; + metadata?: Record; +} + +interface ImageListOptions { + limit?: number; + cursor?: string; + sortOrder?: 'asc' | 'desc'; + creator?: string; +} + +interface ImageList { + images: ImageMetadata[]; + cursor?: string; + listComplete: boolean; +} + interface ImagesBinding { /** * Get image metadata (type, width and height) @@ -115,6 +155,56 @@ interface ImagesBinding { stream: ReadableStream, options?: ImageInputOptions ): ImageTransformer; + + /** + * Get metadata for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns Image metadata, or null if not found + */ + get(imageId: string): Promise; + + /** + * Get the raw image data for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns ReadableStream of image bytes, or null if not found + */ + getImage(imageId: string): Promise | null>; + + /** + * Upload a new hosted image + * @param image The image file to upload + * @param options Upload configuration + * @returns Metadata for the uploaded image + * @throws {@link ImagesError} if upload fails + */ + upload( + image: ReadableStream | ArrayBuffer, + options?: ImageUploadOptions + ): Promise; + + /** + * Update hosted image metadata + * @param imageId The ID of the image + * @param options Properties to update + * @returns Updated image metadata + * @throws {@link ImagesError} if update fails + */ + update(imageId: string, options: ImageUpdateOptions): Promise; + + /** + * Delete a hosted image + * @param imageId The ID of the image + * @returns True if deleted, false if not found + */ + delete(imageId: string): Promise; + + /** + * List hosted images with pagination + * @param options List configuration + * @returns List of images with pagination info + * @throws {@link ImagesError} if list fails + */ + list(options?: ImageListOptions): Promise; } interface ImageTransformer { diff --git a/src/cloudflare/internal/test/images/BUILD.bazel b/src/cloudflare/internal/test/images/BUILD.bazel index a5d15bf1e3f..6f731c82192 100644 --- a/src/cloudflare/internal/test/images/BUILD.bazel +++ b/src/cloudflare/internal/test/images/BUILD.bazel @@ -6,5 +6,6 @@ wd_test( size = "enormous", src = "images-api-test.wd-test", args = ["--experimental"], + compat_date = "2024-04-03", data = glob(["*.js"]) + ["//src/cloudflare/internal/test:instrumentation-test-helper.js"], ) diff --git a/src/cloudflare/internal/test/images/images-api-test.js b/src/cloudflare/internal/test/images/images-api-test.js index 68e2482eaf0..da8c25419d2 100644 --- a/src/cloudflare/internal/test/images/images-api-test.js +++ b/src/cloudflare/internal/test/images/images-api-test.js @@ -451,3 +451,233 @@ export const test_images_base64_small_chunks = { ); }, }; +// GET metadata +export const test_images_get_success = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const metadata = await env.images.get('test-image-id'); + assert.notEqual(metadata, null); + assert.equal(metadata.id, 'test-image-id'); + assert.equal(metadata.filename, 'test.jpg'); + assert.equal(metadata.requireSignedURLs, false); + }, +}; + +export const test_images_get_not_found = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const metadata = await env.images.get('not-found'); + assert.equal(metadata, null); + }, +}; + +// GET image blob +export const test_images_getImage_success = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const stream = await env.images.getImage('test-image-id'); + assert.notEqual(stream, null); + + const reader = stream.getReader(); + let result = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + result += new TextDecoder().decode(value); + } + + assert.equal(result, 'MOCK_IMAGE_DATA_test-image-id'); + }, +}; + +export const test_images_getImage_not_found = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const stream = await env.images.getImage('not-found'); + assert.equal(stream, null); + }, +}; + +// UPLOAD +export const test_images_upload_with_options = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const imageData = new Blob(['test image']).stream(); + const metadata = await env.images.upload(imageData, { + id: 'custom-id', + filename: 'upload-test.jpg', + requireSignedURLs: true, + metadata: { key: 'value' }, + }); + + assert.equal(metadata.id, 'custom-id'); + assert.equal(metadata.filename, 'upload-test.jpg'); + assert.equal(metadata.requireSignedURLs, true); + assert.deepStrictEqual(metadata.meta, { key: 'value' }); + }, +}; + +export const test_images_upload_arraybuffer = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const buffer = new TextEncoder().encode('test image').buffer; + const metadata = await env.images.upload(buffer); + + assert.notEqual(metadata, null); + assert.equal(typeof metadata.id, 'string'); + }, +}; + +// UPDATE +export const test_images_update_success = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const metadata = await env.images.update('test-image-id', { + requireSignedURLs: true, + metadata: { updated: true }, + }); + + assert.equal(metadata.id, 'test-image-id'); + assert.equal(metadata.requireSignedURLs, true); + assert.deepStrictEqual(metadata.meta, { updated: true }); + }, +}; + +export const test_images_update_not_found = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + /** + * @type {any} e; + */ + let e; + try { + await env.images.update('not-found', { requireSignedURLs: true }); + } catch (err) { + e = err; + } + assert.notEqual(e, undefined); + assert.equal(e.message.includes('not found'), true); + }, +}; + +// DELETE +export const test_images_delete_success = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const result = await env.images.delete('test-image-id'); + assert.equal(result, true); + }, +}; + +export const test_images_delete_not_found = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const result = await env.images.delete('not-found'); + assert.equal(result, false); + }, +}; + +// LIST +export const test_images_list_default = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const result = await env.images.list(); + + assert.notEqual(result.images, null); + assert.equal(Array.isArray(result.images), true); + assert.equal(result.images.length, 2); + assert.equal(result.listComplete, true); + }, +}; + +export const test_images_list_with_options = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const result = await env.images.list({ + limit: 1, + sortOrder: 'asc', + }); + + assert.equal(result.images.length, 1); + // TODO(IMAGES-2032): Test cursor once pagination is implemented + }, +}; + +// UPLOAD with base64 encoding +export const test_images_upload_base64_stream = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + // Create base64-encoded data + const imageData = 'test image content'; + const base64Data = btoa(imageData); + const stream = new Blob([base64Data]).stream(); + + const metadata = await env.images.upload(stream, { + filename: 'base64-test.jpg', + encoding: 'base64', + }); + + assert.equal(metadata.filename, 'base64-test.jpg'); + assert.notEqual(metadata.id, null); + }, +}; + +export const test_images_upload_base64_arraybuffer = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + // Create base64-encoded ArrayBuffer + const imageData = 'test image content'; + const base64Data = btoa(imageData); + const buffer = new TextEncoder().encode(base64Data).buffer; + + const metadata = await env.images.upload(buffer, { + filename: 'base64-buffer-test.jpg', + encoding: 'base64', + }); + + assert.equal(metadata.filename, 'base64-buffer-test.jpg'); + assert.notEqual(metadata.id, null); + }, +}; diff --git a/src/cloudflare/internal/test/images/images-api-test.wd-test b/src/cloudflare/internal/test/images/images-api-test.wd-test index 8a807d80368..190874749a6 100644 --- a/src/cloudflare/internal/test/images/images-api-test.wd-test +++ b/src/cloudflare/internal/test/images/images-api-test.wd-test @@ -7,16 +7,21 @@ const unitTests :Workerd.Config = ( modules = [ ( name = "worker", esModule = embed "images-api-test.js" ) ], - compatibilityFlags = ["experimental", "nodejs_compat", "nodejs_compat_v2", "service_binding_extra_handlers", "streams_enable_constructors", "transformstream_enable_standard_constructor", "formdata_parser_supports_files"], + compatibilityFlags = ["experimental", "nodejs_compat", "nodejs_compat_v2", "service_binding_extra_handlers", "rpc", "streams_enable_constructors", "transformstream_enable_standard_constructor", "formdata_parser_supports_files"], streamingTails = ["tail"], bindings = [ ( name = "images", wrapped = ( moduleName = "cloudflare-internal:images-api", - innerBindings = [( - name = "fetcher", - service = "images-upstream-mock" - )], + innerBindings = [ + ( + name = "fetcher", + service = ( + name = "images-upstream-mock", + entrypoint = "ServiceEntrypoint" + ) + ) + ], ) ) ], diff --git a/src/cloudflare/internal/test/images/images-upstream-mock.js b/src/cloudflare/internal/test/images/images-upstream-mock.js index 53ebb115260..56f6618726d 100644 --- a/src/cloudflare/internal/test/images/images-upstream-mock.js +++ b/src/cloudflare/internal/test/images/images-upstream-mock.js @@ -1,7 +1,9 @@ -// Copyright (c) 2024 Cloudflare, Inc. +// Copyright (c) 2025 Cloudflare, Inc. // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 +import { WorkerEntrypoint } from 'cloudflare:workers'; + /** * @param {FormDataEntryValue | null} blob * @returns {Promise} @@ -18,21 +20,141 @@ async function imageAsString(blob) { return blob.text(); } -export default { +export class ServiceEntrypoint extends WorkerEntrypoint { /** + * @param {string} imageId + * @returns {Promise} + */ + async get(imageId) { + if (imageId === 'not-found') { + return null; + } + + return { + id: imageId, + filename: 'test.jpg', + uploaded: '2024-01-01T00:00:00Z', + requireSignedURLs: false, + variants: ['public'], + meta: {}, + draft: false, + }; + } + + async getImage(imageId) { + if (imageId === 'not-found') { + return null; + } + + const mockData = `MOCK_IMAGE_DATA_${imageId}`; + return new Blob([mockData]).stream(); + } + + async upload(image, options) { + // Handle both ReadableStream and ArrayBuffer + const buffer = + image instanceof ArrayBuffer + ? image + : await new Response(image).arrayBuffer(); + + const decoder = new TextDecoder(); + const text = decoder.decode(buffer); + + if (text === 'INVALID') { + throw new Error('Invalid image data'); + } + + return { + id: options?.id || 'generated-id', + filename: options?.filename || 'uploaded.jpg', + uploaded: '2024-01-01T00:00:00Z', + requireSignedURLs: options?.requireSignedURLs || false, + variants: ['public'], + meta: options?.metadata || {}, + draft: false, + }; + } + + /** + * @param {string} imageId + * @param {ImageUpdateOptions} body + * @returns {Promise} + */ + async update(imageId, body) { + if (imageId === 'not-found') { + throw new Error('Image not found'); + } + + return { + id: imageId, + filename: 'updated.jpg', + uploaded: '2024-01-01T00:00:00Z', + requireSignedURLs: + body.requireSignedURLs !== undefined ? body.requireSignedURLs : false, + variants: ['public'], + meta: body.metadata || {}, + draft: false, + }; + } + + /** + * @param {string} imageId + * @returns {Promise} + */ + async delete(imageId) { + return imageId !== 'not-found'; + } + + /** + * @param {ImageListOptions} [options] + * @returns {Promise} + */ + async list(options) { + const images = [ + { + id: 'image-1', + filename: 'test1.jpg', + uploaded: '2024-01-01T00:00:00Z', + requireSignedURLs: false, + variants: ['public'], + meta: {}, + }, + { + id: 'image-2', + filename: 'test2.jpg', + uploaded: '2024-01-02T00:00:00Z', + requireSignedURLs: false, + variants: ['public'], + meta: {}, + }, + ]; + + const limit = options?.limit || 50; + const slicedImages = images.slice(0, limit); + + return { + images: slicedImages, + listComplete: true, + }; + } + + /** + * Handle HTTP requests for info and transform operations. + * In production these go to a separate transformation service, + * but in tests we mock both the ServiceEntrypoint and transformation service in one place. * @param {Request} request + * @returns {Promise} */ async fetch(request) { const form = await request.formData(); const image = (await imageAsString(form.get('image'))) || ''; if (image.includes('BAD')) { - const resp = new Response('ERROR 123: Bad request', { + return new Response('ERROR 123: Bad request', { status: 409, headers: { 'cf-images-binding': 'err=123', }, }); - return resp; } switch (new URL(request.url).pathname) { @@ -49,10 +171,8 @@ export default { height: 123, }); } - case '/transform': - /** - * @type {any} - */ + case '/transform': { + /** @type {any} */ const obj = { image: await imageAsString(form.get('image')), // @ts-ignore @@ -78,8 +198,22 @@ export default { } return Response.json(obj); + } } throw new Error('Unexpected mock invocation'); + } +} + +export default { + /** + * @param {Request} request + * @param {*} env + * @param {ExecutionContext} ctx + * @returns {Promise} + */ + async fetch(request, env, ctx) { + const entrypoint = new ServiceEntrypoint(ctx, env); + return entrypoint.fetch(request); }, }; diff --git a/types/defines/images.d.ts b/types/defines/images.d.ts index 95dccf45be2..01c757d0c05 100644 --- a/types/defines/images.d.ts +++ b/types/defines/images.d.ts @@ -96,6 +96,43 @@ type ImageOutputOptions = { anim?: boolean; }; +interface ImageMetadata { + id: string; + filename?: string; + uploaded?: string; + requireSignedURLs: boolean; + meta?: Record; + variants: string[]; + draft?: boolean; + creator?: string; +} + +interface ImageUploadOptions { + id?: string; + filename?: string; + requireSignedURLs?: boolean; + metadata?: Record; + encoding?: 'base64'; +} + +interface ImageUpdateOptions { + requireSignedURLs?: boolean; + metadata?: Record; +} + +interface ImageListOptions { + limit?: number; + cursor?: string; + sortOrder?: 'asc' | 'desc'; + creator?: string; +} + +interface ImageList { + images: ImageMetadata[]; + cursor?: string; + listComplete: boolean; +} + interface ImagesBinding { /** * Get image metadata (type, width and height) @@ -115,6 +152,56 @@ interface ImagesBinding { stream: ReadableStream, options?: ImageInputOptions ): ImageTransformer; + + /** + * Get metadata for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns Image metadata, or null if not found + */ + get(imageId: string): Promise; + + /** + * Get the raw image data for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns ReadableStream of image bytes, or null if not found + */ + getImage(imageId: string): Promise | null>; + + /** + * Upload a new hosted image + * @param image The image file to upload + * @param options Upload configuration + * @returns Metadata for the uploaded image + * @throws {@link ImagesError} if upload fails + */ + upload( + image: ReadableStream | ArrayBuffer, + options?: ImageUploadOptions + ): Promise; + + /** + * Update hosted image metadata + * @param imageId The ID of the image + * @param options Properties to update + * @returns Updated image metadata + * @throws {@link ImagesError} if update fails + */ + update(imageId: string, options: ImageUpdateOptions): Promise; + + /** + * Delete a hosted image + * @param imageId The ID of the image + * @returns True if deleted, false if not found + */ + delete(imageId: string): Promise; + + /** + * List hosted images with pagination + * @param options List configuration + * @returns List of images with pagination info + * @throws {@link ImagesError} if list fails + */ + list(options?: ImageListOptions): Promise; } interface ImageTransformer { diff --git a/types/generated-snapshot/experimental/index.d.ts b/types/generated-snapshot/experimental/index.d.ts index d09a886efaf..19db907984e 100755 --- a/types/generated-snapshot/experimental/index.d.ts +++ b/types/generated-snapshot/experimental/index.d.ts @@ -11605,6 +11605,38 @@ type ImageOutputOptions = { background?: string; anim?: boolean; }; +interface ImageMetadata { + id: string; + filename?: string; + uploaded?: string; + requireSignedURLs: boolean; + meta?: Record; + variants: string[]; + draft?: boolean; + creator?: string; +} +interface ImageUploadOptions { + id?: string; + filename?: string; + requireSignedURLs?: boolean; + metadata?: Record; + encoding?: "base64"; +} +interface ImageUpdateOptions { + requireSignedURLs?: boolean; + metadata?: Record; +} +interface ImageListOptions { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + creator?: string; +} +interface ImageList { + images: ImageMetadata[]; + cursor?: string; + listComplete: boolean; +} interface ImagesBinding { /** * Get image metadata (type, width and height) @@ -11624,6 +11656,50 @@ interface ImagesBinding { stream: ReadableStream, options?: ImageInputOptions, ): ImageTransformer; + /** + * Get metadata for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns Image metadata, or null if not found + */ + get(imageId: string): Promise; + /** + * Get the raw image data for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns ReadableStream of image bytes, or null if not found + */ + getImage(imageId: string): Promise | null>; + /** + * Upload a new hosted image + * @param image The image file to upload + * @param options Upload configuration + * @returns Metadata for the uploaded image + * @throws {@link ImagesError} if upload fails + */ + upload( + image: ReadableStream | ArrayBuffer, + options?: ImageUploadOptions, + ): Promise; + /** + * Update hosted image metadata + * @param imageId The ID of the image + * @param options Properties to update + * @returns Updated image metadata + * @throws {@link ImagesError} if update fails + */ + update(imageId: string, options: ImageUpdateOptions): Promise; + /** + * Delete a hosted image + * @param imageId The ID of the image + * @returns True if deleted, false if not found + */ + delete(imageId: string): Promise; + /** + * List hosted images with pagination + * @param options List configuration + * @returns List of images with pagination info + * @throws {@link ImagesError} if list fails + */ + list(options?: ImageListOptions): Promise; } interface ImageTransformer { /** diff --git a/types/generated-snapshot/experimental/index.ts b/types/generated-snapshot/experimental/index.ts index 220683f1315..532e7c7a5ae 100755 --- a/types/generated-snapshot/experimental/index.ts +++ b/types/generated-snapshot/experimental/index.ts @@ -11622,6 +11622,38 @@ export type ImageOutputOptions = { background?: string; anim?: boolean; }; +export interface ImageMetadata { + id: string; + filename?: string; + uploaded?: string; + requireSignedURLs: boolean; + meta?: Record; + variants: string[]; + draft?: boolean; + creator?: string; +} +export interface ImageUploadOptions { + id?: string; + filename?: string; + requireSignedURLs?: boolean; + metadata?: Record; + encoding?: "base64"; +} +export interface ImageUpdateOptions { + requireSignedURLs?: boolean; + metadata?: Record; +} +export interface ImageListOptions { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + creator?: string; +} +export interface ImageList { + images: ImageMetadata[]; + cursor?: string; + listComplete: boolean; +} export interface ImagesBinding { /** * Get image metadata (type, width and height) @@ -11641,6 +11673,50 @@ export interface ImagesBinding { stream: ReadableStream, options?: ImageInputOptions, ): ImageTransformer; + /** + * Get metadata for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns Image metadata, or null if not found + */ + get(imageId: string): Promise; + /** + * Get the raw image data for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns ReadableStream of image bytes, or null if not found + */ + getImage(imageId: string): Promise | null>; + /** + * Upload a new hosted image + * @param image The image file to upload + * @param options Upload configuration + * @returns Metadata for the uploaded image + * @throws {@link ImagesError} if upload fails + */ + upload( + image: ReadableStream | ArrayBuffer, + options?: ImageUploadOptions, + ): Promise; + /** + * Update hosted image metadata + * @param imageId The ID of the image + * @param options Properties to update + * @returns Updated image metadata + * @throws {@link ImagesError} if update fails + */ + update(imageId: string, options: ImageUpdateOptions): Promise; + /** + * Delete a hosted image + * @param imageId The ID of the image + * @returns True if deleted, false if not found + */ + delete(imageId: string): Promise; + /** + * List hosted images with pagination + * @param options List configuration + * @returns List of images with pagination info + * @throws {@link ImagesError} if list fails + */ + list(options?: ImageListOptions): Promise; } export interface ImageTransformer { /** diff --git a/types/generated-snapshot/latest/index.d.ts b/types/generated-snapshot/latest/index.d.ts index 6aa7a7a4222..8568275931d 100755 --- a/types/generated-snapshot/latest/index.d.ts +++ b/types/generated-snapshot/latest/index.d.ts @@ -11001,6 +11001,38 @@ type ImageOutputOptions = { background?: string; anim?: boolean; }; +interface ImageMetadata { + id: string; + filename?: string; + uploaded?: string; + requireSignedURLs: boolean; + meta?: Record; + variants: string[]; + draft?: boolean; + creator?: string; +} +interface ImageUploadOptions { + id?: string; + filename?: string; + requireSignedURLs?: boolean; + metadata?: Record; + encoding?: "base64"; +} +interface ImageUpdateOptions { + requireSignedURLs?: boolean; + metadata?: Record; +} +interface ImageListOptions { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + creator?: string; +} +interface ImageList { + images: ImageMetadata[]; + cursor?: string; + listComplete: boolean; +} interface ImagesBinding { /** * Get image metadata (type, width and height) @@ -11020,6 +11052,50 @@ interface ImagesBinding { stream: ReadableStream, options?: ImageInputOptions, ): ImageTransformer; + /** + * Get metadata for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns Image metadata, or null if not found + */ + get(imageId: string): Promise; + /** + * Get the raw image data for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns ReadableStream of image bytes, or null if not found + */ + getImage(imageId: string): Promise | null>; + /** + * Upload a new hosted image + * @param image The image file to upload + * @param options Upload configuration + * @returns Metadata for the uploaded image + * @throws {@link ImagesError} if upload fails + */ + upload( + image: ReadableStream | ArrayBuffer, + options?: ImageUploadOptions, + ): Promise; + /** + * Update hosted image metadata + * @param imageId The ID of the image + * @param options Properties to update + * @returns Updated image metadata + * @throws {@link ImagesError} if update fails + */ + update(imageId: string, options: ImageUpdateOptions): Promise; + /** + * Delete a hosted image + * @param imageId The ID of the image + * @returns True if deleted, false if not found + */ + delete(imageId: string): Promise; + /** + * List hosted images with pagination + * @param options List configuration + * @returns List of images with pagination info + * @throws {@link ImagesError} if list fails + */ + list(options?: ImageListOptions): Promise; } interface ImageTransformer { /** diff --git a/types/generated-snapshot/latest/index.ts b/types/generated-snapshot/latest/index.ts index e12a44e6d6d..4973546daa4 100755 --- a/types/generated-snapshot/latest/index.ts +++ b/types/generated-snapshot/latest/index.ts @@ -11018,6 +11018,38 @@ export type ImageOutputOptions = { background?: string; anim?: boolean; }; +export interface ImageMetadata { + id: string; + filename?: string; + uploaded?: string; + requireSignedURLs: boolean; + meta?: Record; + variants: string[]; + draft?: boolean; + creator?: string; +} +export interface ImageUploadOptions { + id?: string; + filename?: string; + requireSignedURLs?: boolean; + metadata?: Record; + encoding?: "base64"; +} +export interface ImageUpdateOptions { + requireSignedURLs?: boolean; + metadata?: Record; +} +export interface ImageListOptions { + limit?: number; + cursor?: string; + sortOrder?: "asc" | "desc"; + creator?: string; +} +export interface ImageList { + images: ImageMetadata[]; + cursor?: string; + listComplete: boolean; +} export interface ImagesBinding { /** * Get image metadata (type, width and height) @@ -11037,6 +11069,50 @@ export interface ImagesBinding { stream: ReadableStream, options?: ImageInputOptions, ): ImageTransformer; + /** + * Get metadata for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns Image metadata, or null if not found + */ + get(imageId: string): Promise; + /** + * Get the raw image data for a hosted image + * @param imageId The ID of the image (UUID or custom ID) + * @returns ReadableStream of image bytes, or null if not found + */ + getImage(imageId: string): Promise | null>; + /** + * Upload a new hosted image + * @param image The image file to upload + * @param options Upload configuration + * @returns Metadata for the uploaded image + * @throws {@link ImagesError} if upload fails + */ + upload( + image: ReadableStream | ArrayBuffer, + options?: ImageUploadOptions, + ): Promise; + /** + * Update hosted image metadata + * @param imageId The ID of the image + * @param options Properties to update + * @returns Updated image metadata + * @throws {@link ImagesError} if update fails + */ + update(imageId: string, options: ImageUpdateOptions): Promise; + /** + * Delete a hosted image + * @param imageId The ID of the image + * @returns True if deleted, false if not found + */ + delete(imageId: string): Promise; + /** + * List hosted images with pagination + * @param options List configuration + * @returns List of images with pagination info + * @throws {@link ImagesError} if list fails + */ + list(options?: ImageListOptions): Promise; } export interface ImageTransformer { /** From 826c28b2e2cafc1edfe50744d0596d785b0c8c7a Mon Sep 17 00:00:00 2001 From: Matt Lewis Date: Fri, 16 Jan 2026 15:58:13 +0000 Subject: [PATCH 2/2] Add missing creator field and update tests --- src/cloudflare/internal/images-api.ts | 110 ++++++++++-------- src/cloudflare/internal/images.d.ts | 53 +++++---- .../internal/test/images/images-api-test.js | 33 +++--- .../test/images/images-upstream-mock.js | 9 +- types/defines/images.d.ts | 55 +++++---- 5 files changed, 151 insertions(+), 109 deletions(-) diff --git a/src/cloudflare/internal/images-api.ts b/src/cloudflare/internal/images-api.ts index 4b374d3b76a..aeabb4f7b69 100644 --- a/src/cloudflare/internal/images-api.ts +++ b/src/cloudflare/internal/images-api.ts @@ -245,8 +245,8 @@ function isDrawTransformer(input: unknown): input is DrawTransformer { } interface ServiceEntrypointStub { - get(imageId: string): Promise; - getImage(imageId: string): Promise | null>; + details(imageId: string): Promise; + image(imageId: string): Promise | null>; upload( image: ReadableStream | ArrayBuffer, options?: ImageUploadOptions @@ -256,11 +256,71 @@ interface ServiceEntrypointStub { list(options?: ImageListOptions): Promise; } +class HostedImagesBindingImpl implements HostedImagesBinding { + readonly #fetcher: ServiceEntrypointStub; + + constructor(fetcher: ServiceEntrypointStub) { + this.#fetcher = fetcher; + } + + async details(imageId: string): Promise { + return this.#fetcher.details(imageId); + } + + async image(imageId: string): Promise | null> { + return this.#fetcher.image(imageId); + } + + async upload( + image: ReadableStream | ArrayBuffer, + options?: ImageUploadOptions + ): Promise { + let processedImage: ReadableStream | ArrayBuffer = image; + + if (options?.encoding === 'base64') { + const stream = + image instanceof ReadableStream + ? image + : new ReadableStream({ + start(controller): void { + controller.enqueue(new Uint8Array(image)); + controller.close(); + }, + }); + + processedImage = stream.pipeThrough(createBase64DecoderTransformStream()); + } + + return this.#fetcher.upload(processedImage, options); + } + + async update( + imageId: string, + options: ImageUpdateOptions + ): Promise { + return this.#fetcher.update(imageId, options); + } + + async delete(imageId: string): Promise { + return this.#fetcher.delete(imageId); + } + + async list(options?: ImageListOptions): Promise { + return this.#fetcher.list(options || {}); + } +} + class ImagesBindingImpl implements ImagesBinding { readonly #fetcher: Fetcher & ServiceEntrypointStub; + readonly #hosted: HostedImagesBinding; constructor(fetcher: Fetcher & ServiceEntrypointStub) { this.#fetcher = fetcher; + this.#hosted = new HostedImagesBindingImpl(fetcher); + } + + get hosted(): HostedImagesBinding { + return this.#hosted; } async info( @@ -327,52 +387,6 @@ class ImagesBindingImpl implements ImagesBinding { return new ImageTransformerImpl(this.#fetcher, decodedStream); } - - async get(imageId: string): Promise { - return await this.#fetcher.get(imageId); - } - - async getImage(imageId: string): Promise | null> { - return await this.#fetcher.getImage(imageId); - } - - async upload( - image: ReadableStream | ArrayBuffer, - options?: ImageUploadOptions - ): Promise { - let processedImage: ReadableStream | ArrayBuffer = image; - - if (options?.encoding === 'base64') { - const stream = - image instanceof ReadableStream - ? image - : new ReadableStream({ - start(controller): void { - controller.enqueue(new Uint8Array(image)); - controller.close(); - }, - }); - - processedImage = stream.pipeThrough(createBase64DecoderTransformStream()); - } - - return await this.#fetcher.upload(processedImage, options); - } - - async update( - imageId: string, - options: ImageUpdateOptions - ): Promise { - return await this.#fetcher.update(imageId, options); - } - - async delete(imageId: string): Promise { - return await this.#fetcher.delete(imageId); - } - - async list(options?: ImageListOptions): Promise { - return await this.#fetcher.list(options || {}); - } } class ImagesErrorImpl extends Error implements ImagesError { diff --git a/src/cloudflare/internal/images.d.ts b/src/cloudflare/internal/images.d.ts index 9796c25b4a9..43a0ab53cf5 100644 --- a/src/cloudflare/internal/images.d.ts +++ b/src/cloudflare/internal/images.d.ts @@ -112,6 +112,7 @@ interface ImageUploadOptions { filename?: string; requireSignedURLs?: boolean; metadata?: Record; + creator?: string; /** * If 'base64', the input data will be decoded from base64 before processing */ @@ -121,6 +122,7 @@ interface ImageUploadOptions { interface ImageUpdateOptions { requireSignedURLs?: boolean; metadata?: Record; + creator?: string; } interface ImageListOptions { @@ -136,39 +138,20 @@ interface ImageList { listComplete: boolean; } -interface ImagesBinding { - /** - * Get image metadata (type, width and height) - * @throws {@link ImagesError} with code 9412 if input is not an image - * @param stream The image bytes - */ - info( - stream: ReadableStream, - options?: ImageInputOptions - ): Promise; - /** - * Begin applying a series of transformations to an image - * @param stream The image bytes - * @returns A transform handle - */ - input( - stream: ReadableStream, - options?: ImageInputOptions - ): ImageTransformer; - +interface HostedImagesBinding { /** * Get metadata for a hosted image * @param imageId The ID of the image (UUID or custom ID) * @returns Image metadata, or null if not found */ - get(imageId: string): Promise; + details(imageId: string): Promise; /** * Get the raw image data for a hosted image * @param imageId The ID of the image (UUID or custom ID) * @returns ReadableStream of image bytes, or null if not found */ - getImage(imageId: string): Promise | null>; + image(imageId: string): Promise | null>; /** * Upload a new hosted image @@ -207,6 +190,32 @@ interface ImagesBinding { list(options?: ImageListOptions): Promise; } +interface ImagesBinding { + /** + * Get image metadata (type, width and height) + * @throws {@link ImagesError} with code 9412 if input is not an image + * @param stream The image bytes + */ + info( + stream: ReadableStream, + options?: ImageInputOptions + ): Promise; + /** + * Begin applying a series of transformations to an image + * @param stream The image bytes + * @returns A transform handle + */ + input( + stream: ReadableStream, + options?: ImageInputOptions + ): ImageTransformer; + + /** + * Access hosted images CRUD operations + */ + readonly hosted: HostedImagesBinding; +} + interface ImageTransformer { /** * Apply transform next, returning a transform handle. diff --git a/src/cloudflare/internal/test/images/images-api-test.js b/src/cloudflare/internal/test/images/images-api-test.js index da8c25419d2..0425b117b49 100644 --- a/src/cloudflare/internal/test/images/images-api-test.js +++ b/src/cloudflare/internal/test/images/images-api-test.js @@ -458,11 +458,12 @@ export const test_images_get_success = { * @param {Env} env */ async test(_, env) { - const metadata = await env.images.get('test-image-id'); + const metadata = await env.images.hosted.details('test-image-id'); assert.notEqual(metadata, null); assert.equal(metadata.id, 'test-image-id'); assert.equal(metadata.filename, 'test.jpg'); assert.equal(metadata.requireSignedURLs, false); + assert.equal(metadata.creator, 'test-creator'); }, }; @@ -472,7 +473,7 @@ export const test_images_get_not_found = { * @param {Env} env */ async test(_, env) { - const metadata = await env.images.get('not-found'); + const metadata = await env.images.hosted.details('not-found'); assert.equal(metadata, null); }, }; @@ -484,7 +485,7 @@ export const test_images_getImage_success = { * @param {Env} env */ async test(_, env) { - const stream = await env.images.getImage('test-image-id'); + const stream = await env.images.hosted.image('test-image-id'); assert.notEqual(stream, null); const reader = stream.getReader(); @@ -505,7 +506,7 @@ export const test_images_getImage_not_found = { * @param {Env} env */ async test(_, env) { - const stream = await env.images.getImage('not-found'); + const stream = await env.images.hosted.image('not-found'); assert.equal(stream, null); }, }; @@ -518,17 +519,19 @@ export const test_images_upload_with_options = { */ async test(_, env) { const imageData = new Blob(['test image']).stream(); - const metadata = await env.images.upload(imageData, { + const metadata = await env.images.hosted.upload(imageData, { id: 'custom-id', filename: 'upload-test.jpg', requireSignedURLs: true, metadata: { key: 'value' }, + creator: 'upload-creator', }); assert.equal(metadata.id, 'custom-id'); assert.equal(metadata.filename, 'upload-test.jpg'); assert.equal(metadata.requireSignedURLs, true); assert.deepStrictEqual(metadata.meta, { key: 'value' }); + assert.equal(metadata.creator, 'upload-creator'); }, }; @@ -539,7 +542,7 @@ export const test_images_upload_arraybuffer = { */ async test(_, env) { const buffer = new TextEncoder().encode('test image').buffer; - const metadata = await env.images.upload(buffer); + const metadata = await env.images.hosted.upload(buffer); assert.notEqual(metadata, null); assert.equal(typeof metadata.id, 'string'); @@ -553,14 +556,16 @@ export const test_images_update_success = { * @param {Env} env */ async test(_, env) { - const metadata = await env.images.update('test-image-id', { + const metadata = await env.images.hosted.update('test-image-id', { requireSignedURLs: true, metadata: { updated: true }, + creator: 'update-creator', }); assert.equal(metadata.id, 'test-image-id'); assert.equal(metadata.requireSignedURLs, true); assert.deepStrictEqual(metadata.meta, { updated: true }); + assert.equal(metadata.creator, 'update-creator'); }, }; @@ -575,7 +580,7 @@ export const test_images_update_not_found = { */ let e; try { - await env.images.update('not-found', { requireSignedURLs: true }); + await env.images.hosted.update('not-found', { requireSignedURLs: true }); } catch (err) { e = err; } @@ -591,7 +596,7 @@ export const test_images_delete_success = { * @param {Env} env */ async test(_, env) { - const result = await env.images.delete('test-image-id'); + const result = await env.images.hosted.delete('test-image-id'); assert.equal(result, true); }, }; @@ -602,7 +607,7 @@ export const test_images_delete_not_found = { * @param {Env} env */ async test(_, env) { - const result = await env.images.delete('not-found'); + const result = await env.images.hosted.delete('not-found'); assert.equal(result, false); }, }; @@ -614,7 +619,7 @@ export const test_images_list_default = { * @param {Env} env */ async test(_, env) { - const result = await env.images.list(); + const result = await env.images.hosted.list(); assert.notEqual(result.images, null); assert.equal(Array.isArray(result.images), true); @@ -629,7 +634,7 @@ export const test_images_list_with_options = { * @param {Env} env */ async test(_, env) { - const result = await env.images.list({ + const result = await env.images.hosted.list({ limit: 1, sortOrder: 'asc', }); @@ -651,7 +656,7 @@ export const test_images_upload_base64_stream = { const base64Data = btoa(imageData); const stream = new Blob([base64Data]).stream(); - const metadata = await env.images.upload(stream, { + const metadata = await env.images.hosted.upload(stream, { filename: 'base64-test.jpg', encoding: 'base64', }); @@ -672,7 +677,7 @@ export const test_images_upload_base64_arraybuffer = { const base64Data = btoa(imageData); const buffer = new TextEncoder().encode(base64Data).buffer; - const metadata = await env.images.upload(buffer, { + const metadata = await env.images.hosted.upload(buffer, { filename: 'base64-buffer-test.jpg', encoding: 'base64', }); diff --git a/src/cloudflare/internal/test/images/images-upstream-mock.js b/src/cloudflare/internal/test/images/images-upstream-mock.js index 56f6618726d..31b3ed69fa4 100644 --- a/src/cloudflare/internal/test/images/images-upstream-mock.js +++ b/src/cloudflare/internal/test/images/images-upstream-mock.js @@ -25,7 +25,7 @@ export class ServiceEntrypoint extends WorkerEntrypoint { * @param {string} imageId * @returns {Promise} */ - async get(imageId) { + async details(imageId) { if (imageId === 'not-found') { return null; } @@ -38,10 +38,11 @@ export class ServiceEntrypoint extends WorkerEntrypoint { variants: ['public'], meta: {}, draft: false, + creator: 'test-creator', }; } - async getImage(imageId) { + async image(imageId) { if (imageId === 'not-found') { return null; } @@ -72,6 +73,7 @@ export class ServiceEntrypoint extends WorkerEntrypoint { variants: ['public'], meta: options?.metadata || {}, draft: false, + creator: options?.creator, }; } @@ -94,6 +96,7 @@ export class ServiceEntrypoint extends WorkerEntrypoint { variants: ['public'], meta: body.metadata || {}, draft: false, + creator: body.creator, }; } @@ -118,6 +121,7 @@ export class ServiceEntrypoint extends WorkerEntrypoint { requireSignedURLs: false, variants: ['public'], meta: {}, + creator: 'test-creator', }, { id: 'image-2', @@ -126,6 +130,7 @@ export class ServiceEntrypoint extends WorkerEntrypoint { requireSignedURLs: false, variants: ['public'], meta: {}, + creator: 'test-creator', }, ]; diff --git a/types/defines/images.d.ts b/types/defines/images.d.ts index 01c757d0c05..984cd6cea7f 100644 --- a/types/defines/images.d.ts +++ b/types/defines/images.d.ts @@ -112,12 +112,14 @@ interface ImageUploadOptions { filename?: string; requireSignedURLs?: boolean; metadata?: Record; + creator?: string; encoding?: 'base64'; } interface ImageUpdateOptions { requireSignedURLs?: boolean; metadata?: Record; + creator?: string; } interface ImageListOptions { @@ -133,39 +135,20 @@ interface ImageList { listComplete: boolean; } -interface ImagesBinding { - /** - * Get image metadata (type, width and height) - * @throws {@link ImagesError} with code 9412 if input is not an image - * @param stream The image bytes - */ - info( - stream: ReadableStream, - options?: ImageInputOptions - ): Promise; +interface HostedImagesBinding { /** - * Begin applying a series of transformations to an image - * @param stream The image bytes - * @returns A transform handle - */ - input( - stream: ReadableStream, - options?: ImageInputOptions - ): ImageTransformer; - - /** - * Get metadata for a hosted image + * Get detailed metadata for a hosted image * @param imageId The ID of the image (UUID or custom ID) * @returns Image metadata, or null if not found */ - get(imageId: string): Promise; + details(imageId: string): Promise; /** * Get the raw image data for a hosted image * @param imageId The ID of the image (UUID or custom ID) * @returns ReadableStream of image bytes, or null if not found */ - getImage(imageId: string): Promise | null>; + image(imageId: string): Promise | null>; /** * Upload a new hosted image @@ -204,6 +187,32 @@ interface ImagesBinding { list(options?: ImageListOptions): Promise; } +interface ImagesBinding { + /** + * Get image metadata (type, width and height) + * @throws {@link ImagesError} with code 9412 if input is not an image + * @param stream The image bytes + */ + info( + stream: ReadableStream, + options?: ImageInputOptions + ): Promise; + /** + * Begin applying a series of transformations to an image + * @param stream The image bytes + * @returns A transform handle + */ + input( + stream: ReadableStream, + options?: ImageInputOptions + ): ImageTransformer; + + /** + * Access hosted images CRUD operations + */ + readonly hosted: HostedImagesBinding; +} + interface ImageTransformer { /** * Apply transform next, returning a transform handle.