// 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, RpcTarget } from 'cloudflare:workers'; /** * @param {FormDataEntryValue | null} blob * @returns {Promise} */ async function imageAsString(blob) { if (blob === null) { return null; } if (typeof blob === 'string') { return null; } return blob.text(); } class ImageHandleMock extends RpcTarget { /** @type {string} */ #imageId; /** @param {string} imageId */ constructor(imageId) { super(); this.#imageId = imageId; } async details() { if (this.#imageId === 'not-found') { return null; } return { id: this.#imageId, filename: 'test.jpg', uploaded: '2024-01-01T00:00:00Z', requireSignedURLs: false, variants: ['public'], meta: {}, draft: false, creator: 'test-creator', }; } async bytes() { if (this.#imageId === 'not-found') { return null; } const mockData = `MOCK_IMAGE_DATA_${this.#imageId}`; return new Blob([mockData]).stream(); } /** * @param {ImageUpdateOptions} body * @returns {Promise} */ async update(body) { if (this.#imageId === 'not-found') { throw new Error('Image not found'); } return { id: this.#imageId, filename: 'updated.jpg', uploaded: '2024-01-01T00:00:00Z', requireSignedURLs: body.requireSignedURLs !== undefined ? body.requireSignedURLs : false, variants: ['public'], meta: body.metadata || {}, draft: false, creator: body.creator, }; } /** * @returns {Promise} */ async delete() { return this.#imageId !== 'not-found'; } } export class ServiceEntrypoint extends WorkerEntrypoint { /** * @param {string} imageId * @returns {ImageHandleMock} */ image(imageId) { return new ImageHandleMock(imageId); } 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, creator: options?.creator, }; } /** * @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: {}, creator: 'test-creator', }, { id: 'image-2', filename: 'test2.jpg', uploaded: '2024-01-02T00:00:00Z', requireSignedURLs: false, variants: ['public'], meta: {}, creator: 'test-creator', }, ]; 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')) { return new Response('ERROR 123: Bad request', { status: 409, headers: { 'cf-images-binding': 'err=123', }, }); } switch (new URL(request.url).pathname) { case '/info': if (image.includes('} */ async fetch(request, env, ctx) { const entrypoint = new ServiceEntrypoint(ctx, env); return entrypoint.fetch(request); }, };