Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/cloudflare-image-quality-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@emdash-cms/cloudflare": patch
"emdash": patch
---

Fixes oversized image renditions on Cloudflare: transforms without an explicit quality were encoded near-losslessly by the Images binding (a 2048px WebP came out ~900 KB instead of ~100 KB). Lossy transforms (WebP/AVIF/JPEG) now default to quality 85, matching Cloudflare's image-resizing default. PNG output keeps no explicit quality — an explicit PNG quality switches the binding to lossy PNG8, which is not a safe default for a lossless format. An explicit `?q=` in the request URL still wins for every format.

Note: image responses are cached with `Cache-Control: immutable, max-age=31536000` keyed on the request URL, so previously-served oversized renditions stay cached at the edge/browser until they expire. To benefit immediately after upgrading, purge the Cloudflare cache for your `/_image*` URLs (or the whole site cache).
9 changes: 8 additions & 1 deletion packages/cloudflare/src/image-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
matchInternalMediaKey,
originalMediaHeaders,
parseTransformParams,
resolveTransformQuality,
type ImageTransformFormat,
} from "emdash/media/image-endpoint";

Expand Down Expand Up @@ -86,8 +87,14 @@ export const GET: APIRoute = async (ctx) => {
const transform: ImageTransform = {};
if (width) transform.width = width;
if (height) transform.height = height;
// Lossy formats get an explicit quality: the Images binding has no
// default of its own and encodes near-losslessly without one, producing
// renditions several times the size of the original. PNG is exempt —
// an explicit PNG quality switches the binding to lossy PNG8, which is
// not a safe default for a lossless format. An explicit `?q=` always wins.
const output: ImageOutputOptions = { format: outputMime };
if (quality) output.quality = quality;
const effectiveQuality = resolveTransformQuality(format, quality);
if (effectiveQuality !== undefined) output.quality = effectiveQuality;

const result = await images.input(source.body).transform(transform).output(output);
const response = result.response();
Expand Down
40 changes: 38 additions & 2 deletions packages/core/src/media/image-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ export const ALLOWED_TRANSFORM_FORMATS = ["webp", "avif", "jpeg", "png"] as cons
/** Default output format -- broad support, strong compression. */
export const DEFAULT_TRANSFORM_FORMAT: ImageTransformFormat = "webp";

/**
* Default output quality for lossy formats (WebP/AVIF/JPEG) when the request
* doesn't specify one. Matches the default Cloudflare applies to URL-based
* image transformations. The Images *binding* applies no default of its own
* and encodes near-losslessly when quality is omitted (a 2048px WebP comes
* out ~900 KB instead of ~100 KB), so the endpoint sends an explicit quality
* for lossy output. PNG is exempt: an explicit PNG quality switches the
* binding to lossy PNG8, which is not a safe default for a lossless format.
*/
export const DEFAULT_TRANSFORM_QUALITY = 85;

/** Upper bound for a requested dimension; caps the work a single request asks for. */
export const MAX_TRANSFORM_DIMENSION = 4000;

Expand All @@ -31,6 +42,11 @@ export interface ImageTransformOptions {
width?: number;
height?: number;
format: ImageTransformFormat;
/**
* Explicitly-requested quality (1-100), or `undefined` when the request
* carried no `q`. Callers apply their own default per format (see
* {@link DEFAULT_TRANSFORM_QUALITY}); lossless PNG deliberately gets none.
*/
quality?: number;
}

Expand Down Expand Up @@ -117,10 +133,30 @@ export type ParsedTransformParams =
| { ok: true; options: ImageTransformOptions }
| { ok: false; message: string };

/**
* Resolve the quality to send to the image binding for a transform. An
* explicitly-requested quality always wins. Otherwise lossy formats
* (WebP/AVIF/JPEG) get {@link DEFAULT_TRANSFORM_QUALITY} because the Images
* binding encodes near-losslessly when quality is omitted; lossless PNG gets
* `undefined` because an explicit PNG quality switches the binding to lossy
* PNG8, which is not a safe default for a lossless format.
*/
export function resolveTransformQuality(
format: ImageTransformFormat,
requested: number | undefined,
): number | undefined {
if (requested !== undefined) return requested;
return format === "png" ? undefined : DEFAULT_TRANSFORM_QUALITY;
}

/**
* Parse and validate `?w=&h=&f=&q=` query params. Width is required (it sizes
* the rendition); dimensions are bounded so a request can't ask for an
* unbounded or nonsensical transform.
* unbounded or nonsensical transform. Format falls back to
* {@link DEFAULT_TRANSFORM_FORMAT} when not requested. `q` is validated when
* present but otherwise left `undefined` so the caller can apply a per-format
* default (lossy formats get one, lossless PNG does not — see
* {@link DEFAULT_TRANSFORM_QUALITY}).
*/
export function parseTransformParams(params: URLSearchParams): ParsedTransformParams {
const width = parseDimension(params.get("w"));
Expand All @@ -139,8 +175,8 @@ export function parseTransformParams(params: URLSearchParams): ParsedTransformPa
format = formatRaw;
}

const qualityRaw = params.get("q");
let quality: number | undefined;
const qualityRaw = params.get("q");
if (qualityRaw !== null) {
const q = Number(qualityRaw);
if (!Number.isInteger(q) || q < 1 || q > 100) {
Expand Down
29 changes: 27 additions & 2 deletions packages/core/tests/unit/media/image-endpoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import {
matchInternalMediaKey,
isSafeTransformKey,
parseTransformParams,
resolveTransformQuality,
isTransformFormat,
originalMediaHeaders,
DEFAULT_TRANSFORM_QUALITY,
MAX_TRANSFORM_DIMENSION,
} from "../../../src/media/image-endpoint.js";

Expand Down Expand Up @@ -94,9 +96,15 @@ describe("parseTransformParams", () => {
});
});

it("defaults format to webp and leaves height/quality undefined", () => {
it("defaults format to webp, leaves quality and height undefined when not requested", () => {
// Quality stays undefined when no `q` is requested: the Cloudflare
// endpoint applies DEFAULT_TRANSFORM_QUALITY for lossy formats itself,
// while lossless PNG must get none (explicit PNG quality → lossy PNG8).
const r = parse("w=800");
expect(r).toEqual({ ok: true, options: { width: 800, format: "webp" } });
expect(r).toEqual({
ok: true,
options: { width: 800, format: "webp", quality: undefined },
});
});

it("rejects out-of-range and non-integer dimensions", () => {
Expand All @@ -121,6 +129,23 @@ describe("parseTransformParams", () => {
});
});

describe("resolveTransformQuality", () => {
it("applies the default quality to lossy formats when none is requested", () => {
expect(resolveTransformQuality("webp", undefined)).toBe(DEFAULT_TRANSFORM_QUALITY);
expect(resolveTransformQuality("avif", undefined)).toBe(DEFAULT_TRANSFORM_QUALITY);
expect(resolveTransformQuality("jpeg", undefined)).toBe(DEFAULT_TRANSFORM_QUALITY);
});

it("sends no quality for lossless PNG when none is requested (avoids PNG8)", () => {
expect(resolveTransformQuality("png", undefined)).toBeUndefined();
});

it("honors an explicitly requested quality for every format, including PNG", () => {
expect(resolveTransformQuality("webp", 70)).toBe(70);
expect(resolveTransformQuality("png", 70)).toBe(70);
});
});

describe("originalMediaHeaders", () => {
it("renders safe raster types inline with a sandbox CSP", () => {
const h = originalMediaHeaders("image/png");
Expand Down
Loading