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
6 changes: 6 additions & 0 deletions .changeset/storage-backed-image-optimization.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion e2e/fixture-cloudflare/emdash-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

/// <reference types="emdash/locals" />

import type { ContentBylineCredit, PortableTextBlock } from "emdash";
import type { ContentBylineCredit, TaxonomyTerm, PortableTextBlock } from "emdash";

export interface Page {
id: string;
Expand All @@ -15,6 +15,7 @@ export interface Page {
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
terms?: Record<string, TaxonomyTerm[]>;
}

export interface Post {
Expand All @@ -30,6 +31,7 @@ export interface Post {
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
terms?: Record<string, TaxonomyTerm[]>;
}

declare module "emdash" {
Expand Down
6 changes: 4 additions & 2 deletions e2e/fixture/emdash-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

/// <reference types="emdash/locals" />

import type { ContentBylineCredit, PortableTextBlock } from "emdash";
import type { ContentBylineCredit, TaxonomyTerm, PortableTextBlock } from "emdash";

export interface Page {
id: string;
Expand All @@ -15,21 +15,23 @@ export interface Page {
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
terms?: Record<string, TaxonomyTerm[]>;
}

export interface Post {
id: string;
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<string, unknown> };
body?: PortableTextBlock[];
excerpt?: string;
theme_color?: string;
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
terms?: Record<string, TaxonomyTerm[]>;
}

declare module "emdash" {
Expand Down
39 changes: 39 additions & 0 deletions e2e/tests/image-optimization.spec.ts
Original file line number Diff line number Diff line change
@@ -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\//);
});
});
4 changes: 4 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
109 changes: 109 additions & 0 deletions packages/cloudflare/src/image-endpoint.ts
Original file line number Diff line number Diff line change
@@ -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<ImageTransformFormat, ImageOutputOptions["format"]> = {
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<string, unknown>)[name] as ImagesBinding | undefined;
}

function streamOriginal(body: ReadableStream<Uint8Array>, 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 });
}
};
1 change: 1 addition & 0 deletions packages/cloudflare/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
84 changes: 84 additions & 0 deletions packages/core/src/astro/image-endpoint.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<Uint8Array>, 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 });
}
};
Loading
Loading