Skip to content

fix(cloudflare): default image transform quality to 85#2037

Open
swissky wants to merge 3 commits into
emdash-cms:mainfrom
swissky:fix/cloudflare-image-quality-default
Open

fix(cloudflare): default image transform quality to 85#2037
swissky wants to merge 3 commits into
emdash-cms:mainfrom
swissky:fix/cloudflare-image-quality-default

Conversation

@swissky

@swissky swissky commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes oversized image renditions on Cloudflare Workers. The IMAGES binding applies no quality default of its own and encodes near-losslessly when output.quality is omitted. Since Astro's image service only appends q= to /_image URLs when a quality is explicitly configured — and the EmDash Image component never sets one — this was the common path for every media rendition on Cloudflare.

Measured on a production Worker (same source JPEG, 122 KB original):

Rendition without q (before) with q=85 (after)
640×480 WebP 219 KB 26 KB
2048×1536 WebP 901 KB ~100 KB

The "optimized" rendition was up to 7× larger than the original it replaced.

Fix: parseTransformParams now falls back to a new DEFAULT_TRANSFORM_QUALITY (85, matching the default Cloudflare applies to URL-based image transformations) so the Cloudflare endpoint always sends an explicit quality to the binding. An explicit q= in the request still wins. The Node/sharp endpoint is unaffected — it goes through service.parseURL, and sharp applies its own encoder defaults.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Cursor + Claude (Fable 5)

Screenshots / test output

packages/core unit suite: 4721 passed | 3 skipped (355 files). The updated parseTransformParams test asserts the quality default is always present.

The Images binding applies no quality default of its own and encodes
near-losslessly when quality is omitted, so renditions without an
explicit `q` came out several times larger than the original (a 2048px
WebP at ~900 KB instead of ~100 KB with q=85) — defeating the point of
the transform. Astro's image service only appends `q` when a quality is
explicitly configured, so this was the common path for every EmDash
media rendition.

parseTransformParams now falls back to DEFAULT_TRANSFORM_QUALITY (85,
matching Cloudflare's URL-based image-resizing default) and the
Cloudflare endpoint always sends an explicit quality to the binding.
@changeset-bot

changeset-bot Bot commented Jul 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59da73a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
@emdash-cms/cloudflare Patch
emdash Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 14, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/admin@2037

@emdash-cms/auth

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth@2037

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth-atproto@2037

@emdash-cms/blocks

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/blocks@2037

@emdash-cms/cloudflare

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/cloudflare@2037

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/contentful-to-portable-text@2037

emdash

npm i https://pkg.pr.new/emdash-cms/emdash@2037

create-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/create-emdash@2037

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/gutenberg-to-portable-text@2037

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-cli@2037

@emdash-cms/plugin-types

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-types@2037

@emdash-cms/registry-client

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-client@2037

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-lexicons@2037

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@2037

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/sandbox-workerd@2037

@emdash-cms/x402

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/x402@2037

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-moderation@2037

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-atproto@2037

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-audit-log@2037

@emdash-cms/plugin-color

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-color@2037

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-embeds@2037

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-field-kit@2037

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-forms@2037

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-webhook-notifier@2037

commit: 59da73a

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach

This is the right change, solving the right problem, in the right place. The Cloudflare IMAGES binding applies no quality default of its own and encodes near-losslessly when output.quality is omitted; since the EmDash Image component never sets a quality and Astro's image service only appends q= when one is explicitly configured, every media rendition on Cloudflare was going out near-lossless. Defaulting parseTransformParams to DEFAULT_TRANSFORM_QUALITY (85, matching Cloudflare's URL-based transform default) is the minimal, correctly-located fix — and it's the only consumer of parseTransformParams (the Node/sharp endpoint uses service.parseURL and is correctly left untouched, with sharp applying its own encoder defaults).

What I checked

  • Traced every consumer of parseTransformParams / ImageTransformOptions: only the Cloudflare endpoint. Making quality required on ImageTransformOptions is safe — no other construction site exists.
  • Confirmed the Node endpoint (packages/core/src/astro/image-endpoint.ts) doesn't touch parseTransformParams; it goes through service.parseURL(url, imageConfig), so it's unaffected and still gets sharp's own defaults.
  • Image.astro / responsive.ts confirm the /_image URLs generated by the component never carry q=, so the request URL is unchanged by this fix.
  • Quality validation (1–100, integer) is unchanged and still runs when q is supplied; explicit q= still wins. PNG output ignores quality (lossless), so always sending it is harmless.
  • Test updated to assert the default is present — this is a reproducing test (it would have failed before, when quality was undefined).
  • Changeset present for both touched packages (@emdash-cms/cloudflare + emdash), leads with "Fixes", describes the observable effect. No SQL/i18n/locale concerns apply.

The code is correct and clean. One non-blocking operational caveat below.

Caveat

Because the /_image URL (the cache key) is unchanged — the component never appends q= — and the endpoint still returns Cache-Control: public, max-age=31536000, immutable, renditions already cached at the browser/Cloudflare edge for a given URL keep being served for up to a year. The fix therefore takes effect for newly-rendered / not-yet-cached URLs, but does not retroactively replace already-cached oversized renditions. Worth a cache purge on deploy if immediate relief is wanted.

// Always send a quality: the Images binding has no default and encodes
// near-losslessly without one, producing renditions several times the
// size of the original.
const output: ImageOutputOptions = { format: outputMime, quality };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This now always sends quality to the binding, but the request URL (the cache key) is unchanged — the EmDash Image component never appends q= to the /_image URL. Combined with the Cache-Control: public, max-age=31536000, immutable returned below (line 102), renditions already cached at the browser/Cloudflare edge for a given media+width URL keep being served (oversized) for up to a year and won't be regenerated. The fix only takes effect for URLs not yet cached.

Not a code defect — the immutable cache is pre-existing and inherent to any output change at a fixed URL — but worth noting so the deploy can purge the Cloudflare cache for these image URLs, or the cache key could be changed (e.g. include q in the URL the component generates) if immediate relief is desired.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — noted in the changeset that previously-served renditions stay cached under the immutable header until they expire, with the recommendation to purge /_image* URLs after upgrading. I kept the request URL unchanged (no q token): the component only emits widths it wants, and a default-quality change is a one-time migration best handled by a cache purge rather than permanently keying URLs on an internal encoding detail.

@github-actions github-actions Bot added cla: signed review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 14, 2026

@khoinguyenpham04 khoinguyenpham04 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this fix @swissky . A few things, could we make the default quality configurable at the site level?

Also, Cloudflare uses explicit PNG quality to enable PNG8, so we should only apply the default to WebP, AVIF, and JPEG. Keeping quality optional would avoid breaking the public type too.

Previously-served oversized renditions stay cached under the immutable
header until they expire; flag the purge step for upgraders (emdashbot).
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 19, 2026
Addressing review from khoinguyenpham04: an explicit PNG quality switches
the Images binding to lossy PNG8, so applying the lossy default to PNG
would silently degrade a lossless format. parseTransformParams now leaves
quality undefined unless requested (keeping ImageTransformOptions.quality
optional, so the public type is unchanged), and a new resolveTransformQuality
helper applies DEFAULT_TRANSFORM_QUALITY only to lossy WebP/AVIF/JPEG while
letting an explicit ?q= win for every format including PNG.
@github-actions github-actions Bot added size/M and removed size/S labels Jul 19, 2026
@swissky

swissky commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

@khoinguyenpham04 thanks for the review — both points addressed in 59da73a:

PNG8 / format-scoped default: parseTransformParams no longer forces a quality at all — it validates ?q= when present but otherwise leaves quality undefined. A new resolveTransformQuality(format, requested) helper applies DEFAULT_TRANSFORM_QUALITY (85) only to the lossy formats (WebP/AVIF/JPEG) and returns undefined for PNG, so lossless PNG output no longer gets an explicit quality (which, as you noted, would flip the binding to lossy PNG8). An explicit ?q= still wins for every format, PNG included.

Optional quality / public type: ImageTransformOptions.quality is now quality?: number, so the public type is unchanged in shape — existing construction sites and consumers keep compiling.

Coverage: added resolveTransformQuality unit tests asserting the lossy default, the PNG exemption, and explicit-quality pass-through (20/20 in the media suite green; both packages typecheck).

On site-level configurability: agreed that'd be useful, but I'd rather land it as a follow-up — it needs a settings surface plus a cache-busting story (quality is baked into the immutable-cache key). This PR stays the minimal correctness fix; happy to open a Discussion for the configurable-quality feature if you want it tracked.

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants