Skip to content
Closed
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/curvy-pandas-transform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"emdash": minor
"@emdash-cms/cloudflare": minor
---

Add configurable media transforms for files served by EmDash, with a Cloudflare Images adapter that resizes uploaded images on demand and gracefully falls back to the original file when transforms fail.
17 changes: 15 additions & 2 deletions docs/src/content/docs/deployment/cloudflare.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Cloudflare Workers provides a fast, globally distributed runtime for EmDash. Thi

## Configure Bindings

Create `wrangler.jsonc` in your project root with D1 and R2 bindings. Wrangler provisions both resources on the first deploy if they don't already exist.
Create `wrangler.jsonc` in your project root with D1 and R2 bindings. Add an Images binding if you want EmDash to transform uploaded images on demand. Wrangler provisions D1 and R2 resources on the first deploy if they don't already exist.

```jsonc title="wrangler.jsonc"
{
Expand All @@ -37,6 +37,10 @@ Create `wrangler.jsonc` in your project root with D1 and R2 bindings. Wrangler p
"bucket_name": "emdash-media",
},
],

"images": {
"binding": "IMAGES",
},
}
```

Expand All @@ -48,7 +52,7 @@ Update your Astro configuration to use D1 and R2:
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import emdash from "emdash/astro";
import { d1, r2 } from "@emdash-cms/cloudflare";
import { cloudflareImageTransforms, d1, r2 } from "@emdash-cms/cloudflare";

export default defineConfig({
output: "server",
Expand All @@ -57,6 +61,7 @@ export default defineConfig({
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
mediaTransforms: cloudflareImageTransforms({ binding: "IMAGES" }),
}),
],
});
Expand Down Expand Up @@ -96,6 +101,14 @@ You also need to enable read replication on the D1 database itself in the Cloudf

See [Database Options — Read Replicas](/deployment/database/#read-replicas) for session modes and how bookmark-based consistency works.

## Image Transforms

When `mediaTransforms: cloudflareImageTransforms({ binding: "IMAGES" })` is configured, EmDash uses Cloudflare Images to transform JPEG, PNG, WebP, and AVIF files served through `/_emdash/api/media/file/*`.

Transformed image requests can include `?width=800`. EmDash clamps requested widths to the configured `maxWidth`, returns AVIF when the browser advertises `image/avif`, otherwise returns WebP, and adds `Vary: Accept` only to transformed responses.

Transforms are best-effort. If the Images binding is missing, the Images service fails, or a transform times out, EmDash serves the original R2 object instead.

## Custom Domain

Add a custom domain in the Cloudflare dashboard:
Expand Down
25 changes: 25 additions & 0 deletions docs/src/content/docs/guides/media-library.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,31 @@ Works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.
</TabItem>
</Tabs>

## Image Transforms

Uploaded images are always stored in their original form. Sites can optionally configure a media transform adapter to resize or re-encode images when they are served through `/_emdash/api/media/file/*`.

On Cloudflare Workers, use the Cloudflare Images binding adapter:

```js title="astro.config.mjs"
import { cloudflareImageTransforms, r2 } from "@emdash-cms/cloudflare";

emdash({
storage: r2({ binding: "MEDIA" }),
mediaTransforms: cloudflareImageTransforms({ binding: "IMAGES" }),
});
```

```jsonc title="wrangler.jsonc"
{
"images": {
"binding": "IMAGES"
}
}
```

The Cloudflare adapter transforms JPEG, PNG, WebP, and AVIF files. Add `?width=800` to a media file URL to request a smaller image. EmDash negotiates AVIF or WebP from the request `Accept` header and falls back to the original file if the transform binding is unavailable, fails, or times out.

## How Uploads Work

EmDash uses signed URLs for secure uploads:
Expand Down
50 changes: 49 additions & 1 deletion docs/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,30 @@ storage: s3({

See [Storage Options](/deployment/storage/) for details.

### `mediaTransforms`

**Optional.** Media transform adapter for files served through `/_emdash/api/media/file/*`.
Use this to resize or re-encode uploaded media on demand while keeping the original file in storage.

Cloudflare Workers deployments can use Cloudflare Images transforms from `@emdash-cms/cloudflare`:

```js
import { cloudflareImageTransforms, r2 } from "@emdash-cms/cloudflare";

emdash({
storage: r2({ binding: "MEDIA" }),
mediaTransforms: cloudflareImageTransforms({
binding: "IMAGES",
timeoutMs: 10_000,
defaultWidth: 1600,
maxWidth: 2400,
quality: 85,
}),
});
```

The transform path is best-effort: if the adapter is not configured, the binding is missing, or a transform fails or times out, EmDash serves the original file from storage.

### `plugins`

**Optional.** Array of EmDash plugins. The following example registers one plugin:
Expand Down Expand Up @@ -480,7 +504,7 @@ Import `local` and `s3` from `emdash/astro`. The `r2` adapter is imported from `

```js
import emdash, { local, s3 } from "emdash/astro";
import { r2 } from "@emdash-cms/cloudflare";
import { cloudflareImageTransforms, r2 } from "@emdash-cms/cloudflare";
```

### `local(config)`
Expand Down Expand Up @@ -515,6 +539,30 @@ r2({
});
```

### `cloudflareImageTransforms(config)`

Cloudflare Images binding adapter for on-the-fly transforms of media served by EmDash's media file route. This helper is imported from `@emdash-cms/cloudflare` and configured on the `mediaTransforms` integration option.

| Option | Type | Description |
| -------------- | -------- | ------------------------------------------------ |
| `binding` | `string` | Images binding name |
| `timeoutMs` | `number` | Optional transform timeout, default `10000` |
| `defaultWidth` | `number` | Optional width when `?width=` is absent, default `1600` |
| `maxWidth` | `number` | Optional maximum requested width, default `2400` |
| `quality` | `number` | Optional output quality, default `85` |

```js
cloudflareImageTransforms({
binding: "IMAGES",
timeoutMs: 10_000,
defaultWidth: 1600,
maxWidth: 2400,
quality: 85,
});
```

The adapter transforms JPEG, PNG, WebP, and AVIF uploads. It selects AVIF when the request `Accept` header includes `image/avif`; otherwise it returns WebP. Requests may include `?width=800` to request a smaller variant, capped by `maxWidth`.

### `s3(config?)`

S3-compatible storage. All config fields are optional: any field omitted from
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/rest-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ DELETE /_emdash/api/media/:id
GET /_emdash/api/media/file/:key
```

Serves the actual file content. For local storage only.
Serves the actual file content from the configured storage adapter. When a media transform adapter is configured, image responses may be resized or re-encoded on demand. Transform failures fall back to the original stored file.

## Revision Endpoints

Expand Down
1 change: 1 addition & 0 deletions e2e/fixtures/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export class AdminPage {
.locator(".animate-spin")
.waitFor({ state: "hidden", timeout: 10000 })
.catch(() => {});
await this.dismissOnboardingModal();
}

// ============================================
Expand Down
6 changes: 5 additions & 1 deletion packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
"types": "./dist/media/images-runtime.d.mts",
"default": "./dist/media/images-runtime.mjs"
},
"./media/image-transforms": {
"types": "./dist/media/image-transforms-runtime.d.mts",
"default": "./dist/media/image-transforms-runtime.mjs"
},
"./media/stream-runtime": {
"types": "./dist/media/stream-runtime.d.mts",
"default": "./dist/media/stream-runtime.mjs"
Expand Down Expand Up @@ -107,4 +111,4 @@
],
"author": "Matt Kane",
"license": "MIT"
}
}
15 changes: 14 additions & 1 deletion packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@
* ```
*/

import type { AuthDescriptor, DatabaseDescriptor, StorageDescriptor } from "emdash";
import type {
AuthDescriptor,
DatabaseDescriptor,
MediaTransformDescriptor,
StorageDescriptor,
} from "emdash";

import type { PreviewDOConfig } from "./db/do-types.js";

Expand Down Expand Up @@ -86,6 +91,14 @@ export interface R2StorageConfig {
publicUrl?: string;
}

export type { CloudflareImageTransformsConfig } from "./media/image-transforms.js";
export {
cloudflareImageTransforms,
CLOUDFLARE_IMAGE_TRANSFORM_TYPES,
} from "./media/image-transforms.js";

export type { MediaTransformDescriptor };

/**
* Configuration for Cloudflare Access authentication
*/
Expand Down
90 changes: 90 additions & 0 deletions packages/cloudflare/src/media/image-transforms-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { env } from "cloudflare:workers";
import type { CreateMediaTransformFn } from "emdash/media";

import type { CloudflareImageTransformsConfig } from "./image-transforms.js";

const DEFAULT_TIMEOUT_MS = 10000;
const DEFAULT_WIDTH = 1600;
const DEFAULT_MAX_WIDTH = 2400;
const DEFAULT_QUALITY = 85;

function imageFormatForRequest(request: Request): "image/avif" | "image/webp" {
const accept = request.headers.get("accept") || "";
const avifAccepted = accept.split(",").some((part) => {
const [mediaType, ...params] = part.split(";").map((value) => value.trim().toLowerCase());
if (mediaType !== "image/avif") return false;
const quality = params.find((param) => param.startsWith("q="));
if (!quality) return true;
const value = Number(quality.slice(2));
return !Number.isFinite(value) || value > 0;
});
return avifAccepted ? "image/avif" : "image/webp";
}

function imageWidthForRequest(request: Request, defaultWidth: number, maxWidth: number): number {
const width = Number(new URL(request.url).searchParams.get("width"));
if (!Number.isFinite(width) || width <= 0) return defaultWidth;
return Math.min(Math.round(width), maxWidth);
}

async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error("Image transform timed out")), timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timeoutId);
}
}

function getImagesBinding(binding: string): ImagesBinding | undefined {
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- Workers bindings are exposed through an untyped env object.
const value = (env as Record<string, unknown>)[binding];
if (!isImagesBinding(value)) return undefined;
return value;
}

function isImagesBinding(value: unknown): value is ImagesBinding {
return (
typeof value === "object" &&
value !== null &&
"input" in value &&
typeof value.input === "function"
);
}

export const createMediaTransform: CreateMediaTransformFn<CloudflareImageTransformsConfig> = (
Comment thread
mvvmm marked this conversation as resolved.
config,
) => {
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const defaultWidth = config.defaultWidth ?? DEFAULT_WIDTH;
const maxWidth = config.maxWidth ?? DEFAULT_MAX_WIDTH;
const quality = config.quality ?? DEFAULT_QUALITY;

return async ({ body, request }) => {
const images = getImagesBinding(config.binding);
if (!images) return null;

const transformed = await withTimeout(
images
.input(body)
.transform({
fit: "scale-down",
width: imageWidthForRequest(request, defaultWidth, maxWidth),
})
.output({
format: imageFormatForRequest(request),
quality,
}),
timeoutMs,
);

return {
body: transformed.image(),
contentType: transformed.contentType(),
headers: { Vary: "Accept" },
};
};
};
31 changes: 31 additions & 0 deletions packages/cloudflare/src/media/image-transforms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { MediaTransformDescriptor } from "emdash";

export interface CloudflareImageTransformsConfig {
/** Name of the Images binding in wrangler.jsonc. */
binding: string;
/** Maximum time to wait for a transformation before serving the original file. */
timeoutMs?: number;
/** Default resize width when the request does not include a valid `?width=`. */
defaultWidth?: number;
/** Maximum allowed resize width. */
maxWidth?: number;
/** Output quality passed to Cloudflare Images. */
quality?: number;
}

export const CLOUDFLARE_IMAGE_TRANSFORM_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
];

export function cloudflareImageTransforms(
config: CloudflareImageTransformsConfig,
): MediaTransformDescriptor {
return {
entrypoint: "@emdash-cms/cloudflare/media/image-transforms",
config,
contentTypes: CLOUDFLARE_IMAGE_TRANSFORM_TYPES,
};
}
Loading
Loading