Skip to content

Commit

Permalink
Add Images binding local mode
Browse files Browse the repository at this point in the history
  • Loading branch information
ns476 committed Dec 3, 2024
1 parent baa6d41 commit 1d9182c
Show file tree
Hide file tree
Showing 5 changed files with 476 additions and 14 deletions.
4 changes: 3 additions & 1 deletion packages/wrangler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,12 @@
"path-to-regexp": "^6.3.0",
"resolve": "^1.22.8",
"selfsigned": "^2.0.1",
"sharp": "^0.33.5",
"source-map": "^0.6.1",
"unenv": "npm:unenv-nightly@2.0.0-20241121-161142-806b5c0",
"workerd": "1.20241106.1",
"xxhash-wasm": "^1.0.1"
"xxhash-wasm": "^1.0.1",
"zod": "^3.22.3"
},
"devDependencies": {
"@cloudflare/cli": "workspace:*",
Expand Down
9 changes: 7 additions & 2 deletions packages/wrangler/src/dev/miniflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ import type { EsbuildBundle } from "./use-esbuild";
import type { MiniflareOptions, SourceOptions, WorkerOptions } from "miniflare";
import type { UUID } from "node:crypto";
import type { Readable } from "node:stream";
import { EXTERNAL_IMAGES_WORKER_NAME, EXTERNAL_IMAGES_WORKER_SCRIPT, imagesFetcher } from "../images/fetcher";
import {
EXTERNAL_IMAGES_WORKER_NAME,
EXTERNAL_IMAGES_WORKER_SCRIPT,
imagesLocalFetcher,
imagesRemoteFetcher,
} from "../images/fetcher";

// This worker proxies all external Durable Objects to the Wrangler session
// where they're defined, and receives all requests from other Wrangler sessions
Expand Down Expand Up @@ -613,7 +618,7 @@ export function buildMiniflareBindingOptions(config: MiniflareBindingsConfig): {
}
],
serviceBindings: {
FETCHER: config.imagesLocalMode ? imagesFetcher : imagesFetcher,
FETCHER: config.imagesLocalMode ? imagesLocalFetcher : imagesRemoteFetcher,
}
});

Expand Down
6 changes: 4 additions & 2 deletions packages/wrangler/src/images/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Headers, Response } from "miniflare";
import { Response } from "miniflare";
import { performApiFetch } from "../cfetch/internal";
import { getAccountId } from "../user";
import type { Request } from "miniflare";
Expand All @@ -15,7 +15,7 @@ export default function (env) {
}
`;

export async function imagesFetcher(request: Request): Promise<Response> {
export async function imagesRemoteFetcher(request: Request): Promise<Response> {
const accountId = await getAccountId();

const url = `/accounts/${accountId}/images_edge/v2/binding/preview${new URL(request.url).pathname}`;
Expand All @@ -30,3 +30,5 @@ export async function imagesFetcher(request: Request): Promise<Response> {
{ headers: res.headers }
);
}

export { imagesLocalFetcher } from './local';
187 changes: 187 additions & 0 deletions packages/wrangler/src/images/local.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { File } from "buffer";
import sharp from "sharp";
import { z } from "zod";
import type { ImageInfoResponse } from "@cloudflare/workers-types/experimental";
import type { Sharp } from "sharp";

const Transform = z.object({
imageIndex: z.number().optional(),
rotate: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
});

const Transforms = z.array(Transform);

export async function imagesLocalFetcher(request: Request): Promise<Response> {
const data = await request.formData();

const body = data.get("image");
if (!body || !(body instanceof File)) {
return errorResponse(400, 9523, "ERROR: Expected image in request");
}

const transformer = sharp(await body.arrayBuffer(), {});

const url = new URL(request.url);

if (url.pathname == "/info") {
return runInfo(transformer);
} else {
const badTransformsResponse = errorResponse(
400,
9523,
"ERROR: Expected JSON array of valid transforms in transforms field"
);
try {
const transformsJson = data.get("transforms");

if (typeof transformsJson !== "string") {
return badTransformsResponse;
}

const transforms = Transforms.safeParse(JSON.parse(transformsJson));

if (!transforms.success) {
return badTransformsResponse;
}

const outputFormat = data.get("output_format");

if (outputFormat != null && typeof outputFormat !== "string") {
return errorResponse(
400,
9523,
"ERROR: Expected output format to be a string if provided"
);
}

return runTransform(transformer, transforms.data, outputFormat);
} catch (e) {
return badTransformsResponse;
}
}
}

async function runInfo(transformer: Sharp): Promise<Response> {
const metadata = await transformer.metadata();

let mime: string | null = null;
switch (metadata.format) {
case "jpeg":
mime = "image/jpeg";
break;
case "svg":
mime = "image/svg+xml";
break;
case "png":
mime = "image/png";
break;
case "webp":
mime = "image/webp";
break;
case "gif":
mime = "image/gif";
break;
case "avif":
mime = "image/avif";
break;
default:
return errorResponse(415, 9520, "ERROR: Unsupported image type");
}

let resp: ImageInfoResponse;
if (mime == "image/svg+xml") {
resp = {
format: mime,
};
} else {
if (!metadata.size || !metadata.width || !metadata.height) {
return errorResponse(
500,
9523,
"ERROR: Expected size, width and height for bitmap input"
);
}

resp = {
format: mime,
fileSize: metadata.size,
width: metadata.width,
height: metadata.height,
};
}

return Response.json(resp);
}

async function runTransform(
transformer: Sharp,
transforms: z.infer<typeof Transforms>,
outputFormat: string | null
): Promise<Response> {
for (const transform of transforms) {
if (transform.imageIndex !== undefined && transform.imageIndex !== 0) {
// We don't support draws, and this transform doesn't apply to the root
// image, so skip it
continue;
}

if (transform.rotate !== undefined) {
transformer.rotate(transform.rotate);
}

if (transform.width !== undefined || transform.height !== undefined) {
transformer.resize(transform.width || null, transform.height || null, {
fit: "contain",
});
}
}

switch (outputFormat) {
case "image/avif":
transformer.avif();
break;
case "image/gif":
return errorResponse(
415,
9520,
"ERROR: GIF output is not supported in local mode"
);
case "image/jpeg":
transformer.jpeg();
break;
case "image/png":
transformer.png();
break;
case "image/webp":
transformer.webp();
break;
case "rgb":
case "rgba":
return errorResponse(
415,
9520,
"ERROR: RGB/RGBA output is not supported in local mode"
);
default:
outputFormat = "image/jpeg";
break;
}

return new Response(transformer, {
headers: {
"content-type": outputFormat,
},
});
}

function errorResponse(status: number, code: number, message: string) {
return new Response(`ERROR ${code}: ${message}`, {
status,
headers: {
"content-type": "text/plain",
"cf-images-binding": `err=${code}`,
},
});
}
Loading

0 comments on commit 1d9182c

Please sign in to comment.