diff --git a/.changeset/storage-backed-image-optimization.md b/.changeset/storage-backed-image-optimization.md
new file mode 100644
index 0000000000..b53d952e45
--- /dev/null
+++ b/.changeset/storage-backed-image-optimization.md
@@ -0,0 +1,6 @@
+---
+"emdash": minor
+"@emdash-cms/cloudflare": minor
+---
+
+Fixes responsive image optimization for storage-backed media on Cloudflare. EmDash now wraps Astro's image endpoint to read media bytes directly from your storage adapter instead of fetching them over HTTP, so `Image` and Portable Text images generate a real responsive `srcset` even when the site is behind Cloudflare Access (previously these 404'd and fell back to a full-size image). This is on by default and also removes an internal HTTP round-trip on Node. Set `images: false` in your `emdash()` config to leave Astro's image endpoint untouched.
diff --git a/e2e/fixture-cloudflare/emdash-env.d.ts b/e2e/fixture-cloudflare/emdash-env.d.ts
index eb76158218..f7b5f9e673 100644
--- a/e2e/fixture-cloudflare/emdash-env.d.ts
+++ b/e2e/fixture-cloudflare/emdash-env.d.ts
@@ -3,7 +3,7 @@
///
-import type { ContentBylineCredit, PortableTextBlock } from "emdash";
+import type { ContentBylineCredit, TaxonomyTerm, PortableTextBlock } from "emdash";
export interface Page {
id: string;
@@ -15,6 +15,7 @@ export interface Page {
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
+ terms?: Record;
}
export interface Post {
@@ -30,6 +31,7 @@ export interface Post {
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
+ terms?: Record;
}
declare module "emdash" {
diff --git a/e2e/fixture/emdash-env.d.ts b/e2e/fixture/emdash-env.d.ts
index 918ed35bd8..f7b5f9e673 100644
--- a/e2e/fixture/emdash-env.d.ts
+++ b/e2e/fixture/emdash-env.d.ts
@@ -3,7 +3,7 @@
///
-import type { ContentBylineCredit, PortableTextBlock } from "emdash";
+import type { ContentBylineCredit, TaxonomyTerm, PortableTextBlock } from "emdash";
export interface Page {
id: string;
@@ -15,6 +15,7 @@ export interface Page {
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
+ terms?: Record;
}
export interface Post {
@@ -22,7 +23,7 @@ export interface Post {
slug: string | null;
status: string;
title: string;
- featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
+ featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; provider?: string; previewUrl?: string; meta?: Record };
body?: PortableTextBlock[];
excerpt?: string;
theme_color?: string;
@@ -30,6 +31,7 @@ export interface Post {
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
+ terms?: Record;
}
declare module "emdash" {
diff --git a/e2e/tests/image-optimization.spec.ts b/e2e/tests/image-optimization.spec.ts
new file mode 100644
index 0000000000..07060bbae3
--- /dev/null
+++ b/e2e/tests/image-optimization.spec.ts
@@ -0,0 +1,39 @@
+/**
+ * Image optimization E2E.
+ *
+ * The seed creates a published "Post With Image" whose Portable Text body has an
+ * image block (rendered by EmDashImage at /posts/post-with-image). This asserts
+ * the image flows through Astro's image pipeline (a `/_image` src) and that the
+ * wrapped endpoint serves real image bytes from storage -- not a redirect or
+ * 404. On the Cloudflare target this exercises the storage-backed endpoint that
+ * makes optimization work without an HTTP fetch of the media URL.
+ */
+
+import { test, expect } from "../fixtures";
+
+test.describe("image optimization", () => {
+ test("renders an optimized image served by the wrapped image endpoint", async ({
+ page,
+ request,
+ }) => {
+ const img = page.locator("figure.emdash-image img").first();
+
+ // The workerd dev runner's Vite dep optimizer can transiently 500 a cold
+ // route even after warm-up; reload until the page renders. (Dev-only; the
+ // deployed Worker has no optimizer.)
+ for (let attempt = 0; attempt < 5; attempt++) {
+ await page.goto("/posts/post-with-image");
+ if (await img.isVisible().catch(() => false)) break;
+ await page.waitForTimeout(1000);
+ }
+ await expect(img).toBeVisible();
+
+ const src = await img.getAttribute("src");
+ expect(src, "image src should be optimized via Astro's image endpoint").toContain("/_image");
+
+ // The optimized URL must return real image bytes, not an Access redirect or 404.
+ const res = await request.get(src!);
+ expect(res.status()).toBe(200);
+ expect(res.headers()["content-type"]).toMatch(/^image\//);
+ });
+});
diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json
index c969e382cf..fec123a7b6 100644
--- a/packages/cloudflare/package.json
+++ b/packages/cloudflare/package.json
@@ -37,6 +37,10 @@
"types": "./dist/storage/r2.d.mts",
"default": "./dist/storage/r2.mjs"
},
+ "./image-endpoint": {
+ "types": "./dist/image-endpoint.d.mts",
+ "default": "./dist/image-endpoint.mjs"
+ },
"./auth": {
"types": "./dist/auth/index.d.mts",
"default": "./dist/auth/index.mjs"
diff --git a/packages/cloudflare/src/image-endpoint.ts b/packages/cloudflare/src/image-endpoint.ts
new file mode 100644
index 0000000000..441ecd8d49
--- /dev/null
+++ b/packages/cloudflare/src/image-endpoint.ts
@@ -0,0 +1,109 @@
+/**
+ * Cloudflare image endpoint -- the `image.endpoint` EmDash installs under the
+ * Cloudflare adapter.
+ *
+ * For an EmDash media URL it reads the source bytes straight from the storage
+ * adapter (the R2 binding) and resizes them with the Cloudflare `IMAGES`
+ * binding -- no HTTP fetch, so it works behind Cloudflare Access and with
+ * `global_fetch_strictly_public`. Every other image is delegated to the
+ * adapter's stock transform endpoint unchanged (bundled assets via the `ASSETS`
+ * binding, allowed-remote via fetch).
+ */
+
+// @astrojs/cloudflare's binding-mode transform endpoint; resolved in the consumer.
+import { GET as adapterGET } from "@astrojs/cloudflare/image-transform-endpoint";
+import type { APIRoute } from "astro";
+import { env } from "cloudflare:workers";
+import type { Storage } from "emdash";
+import {
+ IMMUTABLE_IMAGE_CACHE,
+ matchInternalMediaKey,
+ originalMediaHeaders,
+ parseTransformParams,
+ type ImageTransformFormat,
+} from "emdash/media/image-endpoint";
+
+export const prerender = false;
+
+const FORMAT_MIME: Record = {
+ webp: "image/webp",
+ avif: "image/avif",
+ jpeg: "image/jpeg",
+ png: "image/png",
+};
+
+/** Resolve the Images binding by the name the Cloudflare adapter configured. */
+function resolveImagesBinding(): ImagesBinding | undefined {
+ const configured = (globalThis as { __ASTRO_IMAGES_BINDING_NAME?: unknown })
+ .__ASTRO_IMAGES_BINDING_NAME;
+ const name = typeof configured === "string" && configured ? configured : "IMAGES";
+ // env from cloudflare:workers has no index signature, so a cast is needed.
+ // eslint-disable-next-line typescript/no-unsafe-type-assertion -- Images binding accessed from untyped env object
+ return (env as Record)[name] as ImagesBinding | undefined;
+}
+
+function streamOriginal(body: ReadableStream, contentType: string): Response {
+ return new Response(body, { status: 200, headers: originalMediaHeaders(contentType) });
+}
+
+function isNotFound(error: unknown): boolean {
+ return (
+ error instanceof Error &&
+ (error.message.includes("not found") || error.message.includes("NOT_FOUND"))
+ );
+}
+
+export const GET: APIRoute = async (ctx) => {
+ const url = new URL(ctx.request.url);
+ const key = matchInternalMediaKey(url.searchParams.get("href"));
+ // App.Locals.emdash is augmented by `emdash/locals`, not loaded in this
+ // package's compilation; narrow to the field we need.
+ // eslint-disable-next-line typescript/no-unsafe-type-assertion -- App.Locals augmentation lives in the emdash package
+ const storage = (ctx.locals as { emdash?: { storage?: Storage | null } }).emdash?.storage;
+
+ // Not EmDash media, or storage unavailable: let the adapter's endpoint handle
+ // it (bundled assets via ASSETS, allowed remote via fetch).
+ if (!key || !storage) return adapterGET(ctx);
+
+ try {
+ const source = await storage.download(key);
+
+ // Only raster images are transformable; serve anything else unchanged.
+ if (!source.contentType.startsWith("image/")) {
+ return streamOriginal(source.body, source.contentType);
+ }
+
+ const images = resolveImagesBinding();
+ const parsed = parseTransformParams(url.searchParams);
+
+ // No binding or unparseable params: serve the original so the URL resolves.
+ if (!images || !parsed.ok) {
+ return streamOriginal(source.body, source.contentType);
+ }
+
+ const { width, height, format, quality } = parsed.options;
+ const outputMime = FORMAT_MIME[format] ?? "image/webp";
+ const transform: ImageTransform = {};
+ if (width) transform.width = width;
+ if (height) transform.height = height;
+ const output: ImageOutputOptions = { format: outputMime };
+ if (quality) output.quality = quality;
+
+ const result = await images.input(source.body).transform(transform).output(output);
+ const response = result.response();
+ if (!response.body) return new Response(null, { status: 500 });
+
+ return new Response(response.body, {
+ status: 200,
+ headers: {
+ "Content-Type": response.headers.get("Content-Type") ?? outputMime,
+ "Cache-Control": IMMUTABLE_IMAGE_CACHE,
+ "X-Content-Type-Options": "nosniff",
+ },
+ });
+ } catch (error) {
+ if (isNotFound(error)) return new Response("Not Found", { status: 404 });
+ console.error("[emdash] image transform failed:", error);
+ return new Response("Internal Server Error", { status: 500 });
+ }
+};
diff --git a/packages/cloudflare/tsdown.config.ts b/packages/cloudflare/tsdown.config.ts
index dcaa527a8a..58718c12bc 100644
--- a/packages/cloudflare/tsdown.config.ts
+++ b/packages/cloudflare/tsdown.config.ts
@@ -9,6 +9,7 @@ export default defineConfig({
"src/db/playground.ts",
"src/db/playground-middleware.ts",
"src/storage/r2.ts",
+ "src/image-endpoint.ts",
"src/auth/index.ts",
"src/sandbox/index.ts",
"src/worker.ts",
diff --git a/packages/core/package.json b/packages/core/package.json
index 0121ea7d91..44b3fae5c2 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -106,6 +106,14 @@
"types": "./dist/media/local-runtime.d.mts",
"default": "./dist/media/local-runtime.mjs"
},
+ "./media/image-endpoint": {
+ "types": "./dist/media/image-endpoint.d.mts",
+ "default": "./dist/media/image-endpoint.mjs"
+ },
+ "./image-endpoint": {
+ "types": "./dist/astro/image-endpoint.d.mts",
+ "default": "./dist/astro/image-endpoint.mjs"
+ },
"./runtime": {
"types": "./dist/runtime.d.mts",
"default": "./dist/runtime.mjs"
diff --git a/packages/core/src/astro/image-endpoint.ts b/packages/core/src/astro/image-endpoint.ts
new file mode 100644
index 0000000000..21763c800e
--- /dev/null
+++ b/packages/core/src/astro/image-endpoint.ts
@@ -0,0 +1,84 @@
+/**
+ * Node image endpoint -- the `image.endpoint` EmDash installs on non-Cloudflare
+ * platforms whose image service is local (sharp).
+ *
+ * It wraps Astro's generic endpoint: for an EmDash media URL it loads the source
+ * bytes straight from the storage adapter (no HTTP, so it works behind any auth
+ * gate) and runs the configured image service's `transform`; every other image
+ * is delegated to the stock endpoint unchanged.
+ */
+
+import type { APIRoute } from "astro";
+// @ts-ignore - astro/assets internal endpoint, resolved by the consumer's Astro build
+import { GET as genericGET } from "astro/assets/endpoint/generic";
+// @ts-ignore - astro:assets is resolved by the consumer's Astro build
+import { getConfiguredImageService, imageConfig } from "astro:assets";
+
+import {
+ IMMUTABLE_IMAGE_CACHE,
+ matchInternalMediaKey,
+ originalMediaHeaders,
+} from "../media/image-endpoint.js";
+
+export const prerender = false;
+
+const FORMAT_MIME: Record = {
+ webp: "image/webp",
+ avif: "image/avif",
+ png: "image/png",
+ jpeg: "image/jpeg",
+ jpg: "image/jpeg",
+ gif: "image/gif",
+};
+
+function isNotFound(error: unknown): boolean {
+ return (
+ error instanceof Error &&
+ (error.message.includes("not found") || error.message.includes("NOT_FOUND"))
+ );
+}
+
+function streamOriginal(body: ReadableStream, contentType: string): Response {
+ return new Response(body, { status: 200, headers: originalMediaHeaders(contentType) });
+}
+
+export const GET: APIRoute = async (ctx) => {
+ const url = new URL(ctx.request.url);
+ const key = matchInternalMediaKey(url.searchParams.get("href"));
+ const storage = ctx.locals.emdash?.storage;
+
+ // Not EmDash media, or storage unavailable: let the stock endpoint handle it
+ // (bundled assets, allowed remote, `publicUrl` media).
+ if (!key || !storage) return genericGET(ctx);
+
+ const service = await getConfiguredImageService();
+ if (!("transform" in service)) return genericGET(ctx);
+
+ try {
+ const source = await storage.download(key);
+
+ // Only raster images are transformable; serve anything else unchanged.
+ if (!source.contentType.startsWith("image/")) {
+ return streamOriginal(source.body, source.contentType);
+ }
+
+ const transform = await service.parseURL(url, imageConfig);
+ if (!transform) return streamOriginal(source.body, source.contentType);
+
+ const inputBuffer = new Uint8Array(await new Response(source.body).arrayBuffer());
+ const { data, format } = await service.transform(inputBuffer, transform, imageConfig);
+
+ return new Response(data, {
+ status: 200,
+ headers: {
+ "Content-Type": FORMAT_MIME[format] ?? source.contentType,
+ "Cache-Control": IMMUTABLE_IMAGE_CACHE,
+ "X-Content-Type-Options": "nosniff",
+ },
+ });
+ } catch (error) {
+ if (isNotFound(error)) return new Response("Not Found", { status: 404 });
+ console.error("[emdash] image transform failed:", error);
+ return new Response("Internal Server Error", { status: 500 });
+ }
+};
diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts
index 40401d989d..b567a9f4e0 100644
--- a/packages/core/src/astro/integration/index.ts
+++ b/packages/core/src/astro/integration/index.ts
@@ -70,26 +70,21 @@ interface ImageRemotePattern {
/**
* Build `image.remotePatterns` entries so Astro will optimize EmDash media.
*
- * Astro's image services only transform **absolute** URLs whose host is
- * authorized; everything else is passed through unoptimized. We authorize the
- * media sources automatically:
+ * Astro's image services only build a transform URL for sources allowed via
+ * `image.domains` / `image.remotePatterns` (relative URLs are never optimized —
+ * see `isRemoteAllowed`). We authorize the media sources automatically:
*
- * 1. The storage adapter's public URL host (R2 custom domain, S3/CDN), so
- * media served directly from a public bucket is optimized.
+ * 1. The storage adapter's public URL host (R2 custom domain, S3/CDN).
* 2. The site's own origin, scoped to the media proxy route
- * (`/_emdash/api/media/file/**`), so same-origin proxied media (local
- * storage, or R2 without a public URL) is optimized too. The pathname
- * scope keeps Astro's image endpoint from acting as an open proxy for the
- * whole origin. Only registered when `siteUrl` is known at build time;
- * `getPublicOrigin` resolves the matching origin at render time.
- * 3. In `astro dev` the dev-server origin (`localhost:`) isn't known at
- * build time, so we register a host-agnostic pattern scoped to the media
- * route. This is dev-only — it never ships in a production build — so the
- * missing host check can't be abused on a deployed site.
+ * (`/_emdash/api/media/file/**`), so same-origin proxied media is optimized.
+ * The components absolutize the media URL against this origin; EmDash's
+ * wrapped image endpoint then serves the bytes from storage (so the absolute
+ * URL is never fetched). Only registered when `siteUrl` is known at build.
+ * 3. In `astro dev` the dev-server origin isn't known at build time, so we
+ * register a host-agnostic pattern scoped to the media route. Dev-only.
*
- * Returns an empty array when no source is statically known (e.g. a production
- * build using local storage with no `siteUrl`), in which case media renders as
- * a plain `
`.
+ * Returns an empty array when no source is statically known (production build,
+ * local storage, no `siteUrl`), in which case media renders as a plain `
`.
*
* @internal Exported for unit testing.
*/
@@ -147,6 +142,59 @@ export function buildImageRemotePatterns(
return patterns;
}
+/**
+ * Stock image endpoints EmDash may safely replace with its storage-backed
+ * wrapper. Our wrapper delegates non-EmDash images to the platform's transform
+ * endpoint, so we only override endpoints whose transform we can delegate to.
+ */
+const OVERRIDABLE_IMAGE_ENDPOINTS = new Set([
+ "astro/assets/endpoint/generic",
+ "astro/assets/endpoint/node",
+ "astro/assets/endpoint/dev",
+ "@astrojs/cloudflare/image-transform-endpoint",
+]);
+
+/**
+ * Stock endpoints that deliberately don't transform (the user opted into
+ * passthrough). We leave these untouched -- and without a warning, since it's a
+ * supported choice, not a custom endpoint. Overriding would route non-EmDash
+ * images through a transformer the passthrough setup doesn't provide.
+ */
+const PASSTHROUGH_IMAGE_ENDPOINTS = new Set(["@astrojs/cloudflare/image-passthrough-endpoint"]);
+
+/**
+ * Decide which image endpoint to install (if any). EmDash wraps Astro's image
+ * endpoint so EmDash media bytes load from storage; the wrapper delegates other
+ * images back to the platform's stock endpoint.
+ *
+ * Returns `{ entrypoint }` to install, `{ warn }` to skip with a warning (a
+ * custom endpoint we can't delegate to), or `{}` to skip silently (opted out).
+ *
+ * @internal Exported for unit testing.
+ */
+export function resolveImageEndpoint(opts: {
+ imagesDisabled: boolean;
+ currentEntrypoint: string | undefined;
+ isCloudflare: boolean;
+}): { entrypoint?: string; warn?: string } {
+ if (opts.imagesDisabled) return {};
+ const current = opts.currentEntrypoint;
+ if (current === undefined || OVERRIDABLE_IMAGE_ENDPOINTS.has(current)) {
+ return {
+ entrypoint: opts.isCloudflare
+ ? "@emdash-cms/cloudflare/image-endpoint"
+ : "emdash/image-endpoint",
+ };
+ }
+ // A deliberate passthrough setup: leave it alone, no warning.
+ if (PASSTHROUGH_IMAGE_ENDPOINTS.has(current)) return {};
+ return {
+ warn:
+ `A custom image.endpoint (${current}) is configured; EmDash will not wrap ` +
+ `it, so storage-backed media may render unoptimized.`,
+ };
+}
+
// Terminal formatting
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
@@ -402,19 +450,32 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
},
];
- // Authorize media sources for Astro image optimization so the
- // Image components can generate a responsive srcset for R2/S3 and
- // same-origin proxied media. `updateConfig` merges arrays, so any
- // user-configured remotePatterns are preserved.
+ // Authorize media sources so Astro's image service builds transform
+ // URLs for them (it won't optimize an un-allowed source). `updateConfig`
+ // merges arrays, so user-configured remotePatterns are preserved.
const imageRemotePatterns = buildImageRemotePatterns(
resolvedConfig.storage,
resolvedConfig.siteUrl,
command,
);
+ // Wrap Astro's image endpoint so EmDash media bytes load straight from
+ // storage (Access-safe) instead of over HTTP. Skip when the user opts
+ // out or has a custom endpoint we can't delegate back to.
+ const { entrypoint: imageEndpoint, warn: imageEndpointWarning } = resolveImageEndpoint({
+ imagesDisabled: resolvedConfig.images === false,
+ currentEntrypoint: astroConfig.image?.endpoint?.entrypoint,
+ isCloudflare: astroConfig.adapter?.name === "@astrojs/cloudflare",
+ });
+ if (imageEndpointWarning) logger.warn(imageEndpointWarning);
+
+ const imageConfig: Record = {};
+ if (imageRemotePatterns.length) imageConfig.remotePatterns = imageRemotePatterns;
+ if (imageEndpoint) imageConfig.endpoint = { entrypoint: imageEndpoint };
+
updateConfig({
security: securityConfig,
- ...(imageRemotePatterns.length ? { image: { remotePatterns: imageRemotePatterns } } : {}),
+ ...(Object.keys(imageConfig).length ? { image: imageConfig } : {}),
// fonts is a valid AstroConfig key but may not be in the
// type definition for the minimum supported Astro version
...({ fonts: emdashFonts } as Record),
diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts
index 00c200a01d..f46165c046 100644
--- a/packages/core/src/astro/integration/runtime.ts
+++ b/packages/core/src/astro/integration/runtime.ts
@@ -164,6 +164,17 @@ export interface EmDashConfig {
* Storage configuration (for media)
*/
storage?: StorageDescriptor;
+ /**
+ * Image optimization.
+ *
+ * By default EmDash wraps Astro's image endpoint so media served from
+ * storage is optimized through the normal `` / `getImage` pipeline,
+ * loading source bytes directly from the storage adapter (works behind
+ * Cloudflare Access). Set to `false` to leave Astro's image endpoint
+ * untouched -- media then renders as a plain `
` unless your image
+ * service can fetch it over HTTP.
+ */
+ images?: boolean;
/**
* Trusted plugins to load (run in main isolate)
*
diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts
index ca6676d87b..6fe72e4c72 100644
--- a/packages/core/src/astro/middleware.ts
+++ b/packages/core/src/astro/middleware.ts
@@ -477,6 +477,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
collectPageFragments: runtime.collectPageFragments.bind(runtime),
getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage),
+ // Exposed so the wrapped image endpoint (`/_image`) can read media
+ // bytes from storage on the anonymous fast path -- public `
`
+ // requests carry no session.
+ storage: runtime.storage,
} as EmDashHandlers;
} catch {
// Non-fatal — EmDashHead will fall back to base SEO contributions
diff --git a/packages/core/src/media/image-endpoint.ts b/packages/core/src/media/image-endpoint.ts
new file mode 100644
index 0000000000..69d299565e
--- /dev/null
+++ b/packages/core/src/media/image-endpoint.ts
@@ -0,0 +1,167 @@
+/**
+ * Portable helpers shared by the platform image-endpoint modules.
+ *
+ * EmDash wraps Astro's image endpoint (`image.endpoint`) so that source bytes
+ * for EmDash media are read straight from the storage adapter instead of being
+ * fetched over HTTP. The platform endpoint modules (Node: sharp via
+ * `astro:assets`; Cloudflare: the `IMAGES` binding) do the actual transform;
+ * this module holds the platform-agnostic bits they share: recognizing an
+ * EmDash media URL and validating transform query params.
+ *
+ * Kept free of `astro:*` / `virtual:emdash/*` imports so it stays in the
+ * precompiled package and can be unit-tested directly.
+ */
+
+import { INTERNAL_MEDIA_PREFIX } from "./normalize.js";
+
+/** Output formats the wrapped endpoint can produce on Cloudflare. */
+export const ALLOWED_TRANSFORM_FORMATS = ["webp", "avif", "jpeg", "png"] as const;
+
+/** Default output format -- broad support, strong compression. */
+export const DEFAULT_TRANSFORM_FORMAT: ImageTransformFormat = "webp";
+
+/** Upper bound for a requested dimension; caps the work a single request asks for. */
+export const MAX_TRANSFORM_DIMENSION = 4000;
+
+/** A format string accepted by {@link ImageTransformOptions.format}. */
+export type ImageTransformFormat = (typeof ALLOWED_TRANSFORM_FORMATS)[number];
+
+/** Validated options for a single transform. */
+export interface ImageTransformOptions {
+ width?: number;
+ height?: number;
+ format: ImageTransformFormat;
+ quality?: number;
+}
+
+/** Long-lived immutable cache -- transform output is deterministic per key+params. */
+export const IMMUTABLE_IMAGE_CACHE = "public, max-age=31536000, immutable";
+
+/**
+ * Raster types safe to render inline. Anything else (SVG, PDF, ...) is served
+ * as an attachment so it can't execute as an active document. Mirrors the
+ * `/_emdash/api/media/file/{key}` route's allowlist.
+ */
+const SAFE_INLINE_IMAGE_TYPES = new Set([
+ "image/jpeg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+ "image/avif",
+ "image/x-icon",
+]);
+
+/**
+ * Headers for streaming **original** stored bytes (the no-transform fallback).
+ * Carries the same stored-XSS protections as the media file route: a sandbox
+ * CSP, `nosniff`, and `Content-Disposition: attachment` for anything not on the
+ * inline raster allowlist (so a stored SVG can't run scripts in the site
+ * origin). Transformed output is always generated raster and doesn't need this.
+ */
+export function originalMediaHeaders(contentType: string): Record {
+ return {
+ "Content-Type": contentType,
+ "Cache-Control": IMMUTABLE_IMAGE_CACHE,
+ "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy":
+ "sandbox; default-src 'none'; img-src 'self'; style-src 'unsafe-inline'",
+ "Content-Disposition": SAFE_INLINE_IMAGE_TYPES.has(contentType) ? "inline" : "attachment",
+ };
+}
+
+/** Storage keys safe to serve: the flat `{ulid}{ext}` shape, no slashes/traversal. */
+const SAFE_STORAGE_KEY = /^[A-Za-z0-9._-]+$/;
+
+/** Plain decimal digits only -- rejects "1e3", "0x10", "+5", whitespace. */
+const DECIMAL_DIGITS = /^\d+$/;
+
+/** Whether a storage key is safe to resolve against the storage backend. */
+export function isSafeTransformKey(key: string): boolean {
+ return SAFE_STORAGE_KEY.test(key);
+}
+
+/**
+ * If `href` points at the internal EmDash media route
+ * (`/_emdash/api/media/file/{key}`) with a safe key, return the key; otherwise
+ * `null` (the endpoint then delegates to the stock image endpoint for bundled
+ * assets, allowed remote, and `publicUrl` media).
+ *
+ * The component absolutizes same-origin media (Astro only optimizes absolute,
+ * remote-allowed URLs), so `href` is typically `https://site/_emdash/...` but
+ * may be relative. We match on the **pathname** only and never fetch `href` —
+ * the key is read from our own storage — so the host is irrelevant and can't be
+ * an SSRF vector. A dummy base resolves both absolute and relative forms and
+ * strips any query/fragment.
+ */
+export function matchInternalMediaKey(href: string | null | undefined): string | null {
+ if (!href) return null;
+ let pathname: string;
+ try {
+ pathname = new URL(href, "http://localhost").pathname;
+ } catch {
+ return null;
+ }
+ if (!pathname.startsWith(INTERNAL_MEDIA_PREFIX)) return null;
+ const key = pathname.slice(INTERNAL_MEDIA_PREFIX.length);
+ if (!key || !isSafeTransformKey(key)) return null;
+ return key;
+}
+
+/** Type guard for {@link ImageTransformFormat}. */
+export function isTransformFormat(value: string): value is ImageTransformFormat {
+ return (ALLOWED_TRANSFORM_FORMATS as readonly string[]).includes(value);
+}
+
+/** Outcome of parsing transform query params: validated options or an error. */
+export type ParsedTransformParams =
+ | { ok: true; options: ImageTransformOptions }
+ | { ok: false; message: string };
+
+/**
+ * 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.
+ */
+export function parseTransformParams(params: URLSearchParams): ParsedTransformParams {
+ const width = parseDimension(params.get("w"));
+ if (width === null) return { ok: false, message: "Invalid 'w' (width)" };
+ if (width === undefined) return { ok: false, message: "Missing 'w' (width)" };
+
+ const height = parseDimension(params.get("h"));
+ if (height === null) return { ok: false, message: "Invalid 'h' (height)" };
+
+ const formatRaw = params.get("f");
+ let format: ImageTransformFormat = DEFAULT_TRANSFORM_FORMAT;
+ if (formatRaw !== null) {
+ if (!isTransformFormat(formatRaw)) {
+ return { ok: false, message: `Unsupported 'f' (format): ${formatRaw}` };
+ }
+ format = formatRaw;
+ }
+
+ const qualityRaw = params.get("q");
+ let quality: number | undefined;
+ if (qualityRaw !== null) {
+ const q = Number(qualityRaw);
+ if (!Number.isInteger(q) || q < 1 || q > 100) {
+ return { ok: false, message: "Invalid 'q' (quality), expected 1-100" };
+ }
+ quality = q;
+ }
+
+ return { ok: true, options: { width, height, format, quality } };
+}
+
+/**
+ * Parse a dimension query value.
+ * - `undefined`: param absent
+ * - `null`: present but invalid (non-integer, out of range)
+ * - `number`: valid, within [1, MAX_TRANSFORM_DIMENSION]
+ */
+function parseDimension(raw: string | null): number | undefined | null {
+ if (raw === null) return undefined;
+ if (!DECIMAL_DIGITS.test(raw)) return null;
+ const n = Number(raw);
+ if (n < 1 || n > MAX_TRANSFORM_DIMENSION) return null;
+ return n;
+}
diff --git a/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts b/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts
index 8f407df148..6fd165cd66 100644
--- a/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts
+++ b/packages/core/tests/unit/astro/integration/image-remote-patterns.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
-import { buildImageRemotePatterns } from "../../../../src/astro/integration/index.js";
+import {
+ buildImageRemotePatterns,
+ resolveImageEndpoint,
+} from "../../../../src/astro/integration/index.js";
const s3 = (publicUrl?: string) => ({ entrypoint: "x", config: { publicUrl } });
const localStorage = { entrypoint: "x", config: { directory: "./uploads" } };
@@ -56,3 +59,62 @@ describe("buildImageRemotePatterns", () => {
]);
});
});
+
+describe("resolveImageEndpoint", () => {
+ it("installs the Node endpoint on a stock/undefined endpoint", () => {
+ expect(
+ resolveImageEndpoint({
+ imagesDisabled: false,
+ currentEntrypoint: undefined,
+ isCloudflare: false,
+ }),
+ ).toEqual({ entrypoint: "emdash/image-endpoint" });
+ expect(
+ resolveImageEndpoint({
+ imagesDisabled: false,
+ currentEntrypoint: "astro/assets/endpoint/generic",
+ isCloudflare: false,
+ }),
+ ).toEqual({ entrypoint: "emdash/image-endpoint" });
+ });
+
+ it("installs the Cloudflare endpoint under the Cloudflare adapter", () => {
+ expect(
+ resolveImageEndpoint({
+ imagesDisabled: false,
+ currentEntrypoint: "@astrojs/cloudflare/image-transform-endpoint",
+ isCloudflare: true,
+ }),
+ ).toEqual({ entrypoint: "@emdash-cms/cloudflare/image-endpoint" });
+ });
+
+ it("skips silently when images are disabled", () => {
+ expect(
+ resolveImageEndpoint({
+ imagesDisabled: true,
+ currentEntrypoint: undefined,
+ isCloudflare: true,
+ }),
+ ).toEqual({});
+ });
+
+ it("leaves a deliberate passthrough endpoint alone without warning", () => {
+ expect(
+ resolveImageEndpoint({
+ imagesDisabled: false,
+ currentEntrypoint: "@astrojs/cloudflare/image-passthrough-endpoint",
+ isCloudflare: true,
+ }),
+ ).toEqual({});
+ });
+
+ it("warns and skips when a custom endpoint is configured", () => {
+ const result = resolveImageEndpoint({
+ imagesDisabled: false,
+ currentEntrypoint: "./src/my-endpoint.ts",
+ isCloudflare: false,
+ });
+ expect(result.entrypoint).toBeUndefined();
+ expect(result.warn).toMatch(/custom image\.endpoint/);
+ });
+});
diff --git a/packages/core/tests/unit/media/image-endpoint.test.ts b/packages/core/tests/unit/media/image-endpoint.test.ts
new file mode 100644
index 0000000000..ac389a8211
--- /dev/null
+++ b/packages/core/tests/unit/media/image-endpoint.test.ts
@@ -0,0 +1,138 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ matchInternalMediaKey,
+ isSafeTransformKey,
+ parseTransformParams,
+ isTransformFormat,
+ originalMediaHeaders,
+ MAX_TRANSFORM_DIMENSION,
+} from "../../../src/media/image-endpoint.js";
+
+describe("matchInternalMediaKey", () => {
+ it("extracts the key from a relative internal media URL", () => {
+ expect(matchInternalMediaKey("/_emdash/api/media/file/01J5ABC.webp")).toBe("01J5ABC.webp");
+ });
+
+ it("extracts the key from an absolute internal media URL (the component absolutizes)", () => {
+ expect(matchInternalMediaKey("https://example.com/_emdash/api/media/file/01J5ABC.webp")).toBe(
+ "01J5ABC.webp",
+ );
+ // Host-agnostic: the key is read from our own storage, href is never fetched.
+ expect(matchInternalMediaKey("http://localhost:4444/_emdash/api/media/file/a-b_c.png")).toBe(
+ "a-b_c.png",
+ );
+ });
+
+ it("ignores any query string or fragment on the URL", () => {
+ expect(matchInternalMediaKey("/_emdash/api/media/file/x.jpg?foo=1")).toBe("x.jpg");
+ expect(matchInternalMediaKey("https://example.com/_emdash/api/media/file/x.jpg#f")).toBe(
+ "x.jpg",
+ );
+ });
+
+ it("returns null for non-internal URLs", () => {
+ expect(matchInternalMediaKey("/_astro/bundled.abc.png")).toBeNull();
+ expect(matchInternalMediaKey("https://cdn.example.com/x.jpg")).toBeNull();
+ expect(matchInternalMediaKey("/images/foo.jpg")).toBeNull();
+ });
+
+ it("returns null for empty/missing href", () => {
+ expect(matchInternalMediaKey(null)).toBeNull();
+ expect(matchInternalMediaKey(undefined)).toBeNull();
+ expect(matchInternalMediaKey("")).toBeNull();
+ expect(matchInternalMediaKey("/_emdash/api/media/file/")).toBeNull();
+ });
+
+ it("rejects traversal and unsafe key characters", () => {
+ // ".." collapses the path back out of the media prefix
+ expect(matchInternalMediaKey("/_emdash/api/media/file/../secret")).toBeNull();
+ // a slash in the key (sub-path) is not the flat storage-key shape
+ expect(matchInternalMediaKey("/_emdash/api/media/file/a/b.jpg")).toBeNull();
+ // percent-encoding is rejected by the safe-key charset
+ expect(matchInternalMediaKey("/_emdash/api/media/file/x%2e%2e")).toBeNull();
+ });
+});
+
+describe("isSafeTransformKey", () => {
+ it("accepts flat ulid+ext keys", () => {
+ expect(isSafeTransformKey("01J5ABC.webp")).toBe(true);
+ expect(isSafeTransformKey("a-b_c.1.png")).toBe(true);
+ });
+
+ it("rejects slashes, query chars, and whitespace", () => {
+ expect(isSafeTransformKey("a/b")).toBe(false);
+ expect(isSafeTransformKey("../secret")).toBe(false);
+ expect(isSafeTransformKey("x?y")).toBe(false);
+ expect(isSafeTransformKey("x y")).toBe(false);
+ });
+});
+
+describe("isTransformFormat", () => {
+ it("accepts supported formats and rejects others", () => {
+ expect(isTransformFormat("webp")).toBe(true);
+ expect(isTransformFormat("avif")).toBe(true);
+ expect(isTransformFormat("gif")).toBe(false);
+ expect(isTransformFormat("svg")).toBe(false);
+ });
+});
+
+describe("parseTransformParams", () => {
+ const parse = (qs: string) => parseTransformParams(new URLSearchParams(qs));
+
+ it("requires a width", () => {
+ const r = parse("h=200");
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.message).toMatch(/width/i);
+ });
+
+ it("parses width, height, format, quality", () => {
+ const r = parse("w=640&h=480&f=avif&q=80");
+ expect(r).toEqual({
+ ok: true,
+ options: { width: 640, height: 480, format: "avif", quality: 80 },
+ });
+ });
+
+ it("defaults format to webp and leaves height/quality undefined", () => {
+ const r = parse("w=800");
+ expect(r).toEqual({ ok: true, options: { width: 800, format: "webp" } });
+ });
+
+ it("rejects out-of-range and non-integer dimensions", () => {
+ expect(parse("w=0").ok).toBe(false);
+ expect(parse(`w=${MAX_TRANSFORM_DIMENSION + 1}`).ok).toBe(false);
+ expect(parse("w=12.5").ok).toBe(false);
+ expect(parse("w=640&h=-1").ok).toBe(false);
+ });
+
+ it("rejects unsupported format and bad quality", () => {
+ expect(parse("w=640&f=gif").ok).toBe(false);
+ expect(parse("w=640&q=0").ok).toBe(false);
+ expect(parse("w=640&q=101").ok).toBe(false);
+ expect(parse("w=640&q=foo").ok).toBe(false);
+ });
+
+ it("rejects exotic numeric encodings for dimensions", () => {
+ expect(parse("w=1e3").ok).toBe(false);
+ expect(parse("w=0x10").ok).toBe(false);
+ expect(parse("w=+5").ok).toBe(false);
+ expect(parse("w= 5 ").ok).toBe(false);
+ });
+});
+
+describe("originalMediaHeaders", () => {
+ it("renders safe raster types inline with a sandbox CSP", () => {
+ const h = originalMediaHeaders("image/png");
+ expect(h["Content-Type"]).toBe("image/png");
+ expect(h["Content-Disposition"]).toBe("inline");
+ expect(h["X-Content-Type-Options"]).toBe("nosniff");
+ expect(h["Content-Security-Policy"]).toContain("sandbox");
+ });
+
+ it("forces attachment + sandbox for SVG and other active types", () => {
+ expect(originalMediaHeaders("image/svg+xml")["Content-Disposition"]).toBe("attachment");
+ expect(originalMediaHeaders("application/pdf")["Content-Disposition"]).toBe("attachment");
+ expect(originalMediaHeaders("image/svg+xml")["Content-Security-Policy"]).toContain("sandbox");
+ });
+});
diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts
index fe677f4063..7c85123988 100644
--- a/packages/core/tsdown.config.ts
+++ b/packages/core/tsdown.config.ts
@@ -94,6 +94,9 @@ export default defineConfig({
// Media providers
"src/media/index.ts",
"src/media/local-runtime.ts",
+ // Image-endpoint helpers (portable) + the Node image endpoint
+ "src/media/image-endpoint.ts",
+ "src/astro/image-endpoint.ts",
// Runtime exports (depends on virtual modules - for live.config.ts)
"src/runtime.ts",
// Seed engine