From 30fdfb57a17819138183efed30a5a9629e7362f1 Mon Sep 17 00:00:00 2001 From: mvm Date: Mon, 8 Jun 2026 17:43:58 -0500 Subject: [PATCH 1/6] feat(media): add configurable image transforms --- .changeset/curvy-pandas-transform.md | 6 ++ .../content/docs/deployment/cloudflare.mdx | 17 ++- .../src/content/docs/guides/media-library.mdx | 25 +++++ .../content/docs/reference/configuration.mdx | 50 ++++++++- docs/src/content/docs/reference/rest-api.mdx | 2 +- packages/cloudflare/package.json | 6 +- packages/cloudflare/src/index.ts | 15 ++- .../src/media/image-transforms-runtime.ts | 76 +++++++++++++ .../cloudflare/src/media/image-transforms.ts | 31 ++++++ .../tests/media/image-transforms.test.ts | 30 ++++++ packages/cloudflare/tsdown.config.ts | 1 + packages/core/src/astro/integration/index.ts | 1 + .../core/src/astro/integration/runtime.ts | 10 ++ .../src/astro/integration/virtual-modules.ts | 24 +++++ .../core/src/astro/integration/vite-config.ts | 9 ++ .../astro/routes/api/media/file/[...key].ts | 52 +++++++-- packages/core/src/index.ts | 7 ++ packages/core/src/media/index.ts | 8 ++ packages/core/src/media/transform.ts | 31 ++++++ packages/core/src/virtual-modules.d.ts | 9 ++ .../astro/integration/virtual-modules.test.ts | 23 ++++ .../unit/astro/media-file-transform.test.ts | 102 ++++++++++++++++++ packages/core/vitest.config.ts | 2 + 23 files changed, 525 insertions(+), 12 deletions(-) create mode 100644 .changeset/curvy-pandas-transform.md create mode 100644 packages/cloudflare/src/media/image-transforms-runtime.ts create mode 100644 packages/cloudflare/src/media/image-transforms.ts create mode 100644 packages/cloudflare/tests/media/image-transforms.test.ts create mode 100644 packages/core/src/media/transform.ts create mode 100644 packages/core/tests/unit/astro/media-file-transform.test.ts diff --git a/.changeset/curvy-pandas-transform.md b/.changeset/curvy-pandas-transform.md new file mode 100644 index 000000000..3041c150a --- /dev/null +++ b/.changeset/curvy-pandas-transform.md @@ -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. diff --git a/docs/src/content/docs/deployment/cloudflare.mdx b/docs/src/content/docs/deployment/cloudflare.mdx index 63e68aa26..53291a30c 100644 --- a/docs/src/content/docs/deployment/cloudflare.mdx +++ b/docs/src/content/docs/deployment/cloudflare.mdx @@ -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" { @@ -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", + }, } ``` @@ -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", @@ -57,6 +61,7 @@ export default defineConfig({ emdash({ database: d1({ binding: "DB" }), storage: r2({ binding: "MEDIA" }), + mediaTransforms: cloudflareImageTransforms({ binding: "IMAGES" }), }), ], }); @@ -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: diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx index 3f6838182..18aa16f9e 100644 --- a/docs/src/content/docs/guides/media-library.mdx +++ b/docs/src/content/docs/guides/media-library.mdx @@ -134,6 +134,31 @@ Works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services. +## 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: diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index 5dbb96059..851a6f56d 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -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: @@ -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)` @@ -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 diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index 1e38e9c00..f3244d956 100644 --- a/docs/src/content/docs/reference/rest-api.mdx +++ b/docs/src/content/docs/reference/rest-api.mdx @@ -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 diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index c6e6b4eef..a9e58fff5 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -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" @@ -107,4 +111,4 @@ ], "author": "Matt Kane", "license": "MIT" -} \ No newline at end of file +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 5009ae379..86fb23b59 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -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"; @@ -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 */ diff --git a/packages/cloudflare/src/media/image-transforms-runtime.ts b/packages/cloudflare/src/media/image-transforms-runtime.ts new file mode 100644 index 000000000..de06cb23d --- /dev/null +++ b/packages/cloudflare/src/media/image-transforms-runtime.ts @@ -0,0 +1,76 @@ +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") || ""; + return accept.includes("image/avif") ? "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(promise: Promise, timeoutMs: number): Promise { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_, 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)[binding]; + if (!value || typeof (value as { input?: unknown }).input !== "function") return undefined; + return value as ImagesBinding; +} + +export const createMediaTransform: CreateMediaTransformFn = ( + 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 input = new Response(body).body; + if (!input) throw new Error("Unable to create image transform input stream"); + + const transformed = await withTimeout( + images + .input(input) + .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" }, + }; + }; +}; diff --git a/packages/cloudflare/src/media/image-transforms.ts b/packages/cloudflare/src/media/image-transforms.ts new file mode 100644 index 000000000..30e373390 --- /dev/null +++ b/packages/cloudflare/src/media/image-transforms.ts @@ -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, + }; +} diff --git a/packages/cloudflare/tests/media/image-transforms.test.ts b/packages/cloudflare/tests/media/image-transforms.test.ts new file mode 100644 index 000000000..91573c30d --- /dev/null +++ b/packages/cloudflare/tests/media/image-transforms.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { + CLOUDFLARE_IMAGE_TRANSFORM_TYPES, + cloudflareImageTransforms, +} from "../../src/media/image-transforms.js"; + +describe("cloudflareImageTransforms", () => { + it("returns a media transform descriptor for the Cloudflare runtime", () => { + const descriptor = cloudflareImageTransforms({ + binding: "IMAGES", + timeoutMs: 5000, + defaultWidth: 1200, + maxWidth: 2000, + quality: 80, + }); + + expect(descriptor).toEqual({ + entrypoint: "@emdash-cms/cloudflare/media/image-transforms", + config: { + binding: "IMAGES", + timeoutMs: 5000, + defaultWidth: 1200, + maxWidth: 2000, + quality: 80, + }, + contentTypes: CLOUDFLARE_IMAGE_TRANSFORM_TYPES, + }); + }); +}); diff --git a/packages/cloudflare/tsdown.config.ts b/packages/cloudflare/tsdown.config.ts index 2e524c7b4..8318d4e51 100644 --- a/packages/cloudflare/tsdown.config.ts +++ b/packages/cloudflare/tsdown.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "src/plugins/index.ts", // Media provider runtimes "src/media/images-runtime.ts", + "src/media/image-transforms-runtime.ts", "src/media/stream-runtime.ts", // Cache provider "src/cache/runtime.ts", diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index c1aee2607..f6bb66f1d 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -197,6 +197,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { const serializableConfig: Record = { database: resolvedConfig.database, storage: resolvedConfig.storage, + mediaTransforms: resolvedConfig.mediaTransforms, auth: resolvedConfig.auth, authProviders: resolvedConfig.authProviders, marketplace: resolvedConfig.marketplace, diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 61d504a9c..d54ff64d5 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -9,6 +9,7 @@ import type { AuthDescriptor, AuthProviderDescriptor } from "../../auth/types.js"; import type { DatabaseDescriptor } from "../../db/adapters.js"; +import type { MediaTransformDescriptor } from "../../media/transform.js"; import type { MediaProviderDescriptor } from "../../media/types.js"; import type { ResolvedPlugin } from "../../plugins/types.js"; import type { ExperimentalConfig } from "../../registry/types.js"; @@ -151,6 +152,15 @@ export interface EmDashConfig { * Storage configuration (for media) */ storage?: StorageDescriptor; + /** + * Optional media transform adapter for files served through + * `/_emdash/api/media/file/*`. + * + * Platform packages can provide adapters for on-the-fly image resizing or + * format negotiation while core keeps serving the original file if the + * transformer is absent or fails. + */ + mediaTransforms?: MediaTransformDescriptor; /** * Trusted plugins to load (run in main isolate) * diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index f130c2309..2998fa158 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -11,6 +11,7 @@ import { createRequire } from "node:module"; import { resolve } from "node:path"; import type { AuthProviderDescriptor } from "../../auth/types.js"; +import type { MediaTransformDescriptor } from "../../media/transform.js"; import type { MediaProviderDescriptor } from "../../media/types.js"; import { defaultSeed } from "../../seed/default.js"; import type { PluginDescriptor } from "./runtime.js"; @@ -54,6 +55,9 @@ export const RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID = "\0" + VIRTUAL_AUTH_PROVIDERS_ export const VIRTUAL_MEDIA_PROVIDERS_ID = "virtual:emdash/media-providers"; export const RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID = "\0" + VIRTUAL_MEDIA_PROVIDERS_ID; +export const VIRTUAL_MEDIA_TRANSFORM_ID = "virtual:emdash/media-transform"; +export const RESOLVED_VIRTUAL_MEDIA_TRANSFORM_ID = "\0" + VIRTUAL_MEDIA_TRANSFORM_ID; + export const VIRTUAL_BLOCK_COMPONENTS_ID = "virtual:emdash/block-components"; export const RESOLVED_VIRTUAL_BLOCK_COMPONENTS_ID = "\0" + VIRTUAL_BLOCK_COMPONENTS_ID; @@ -125,6 +129,26 @@ export const createStorage = _createStorage; `; } +/** + * Generates the media transform module. + * Statically imports the configured transform adapter. + */ +export function generateMediaTransformModule(descriptor?: MediaTransformDescriptor): string { + if (!descriptor) { + return [ + `export const transformMedia = undefined;`, + `export const transformableContentTypes = undefined;`, + ].join("\n"); + } + + return ` +import { createMediaTransform as _createMediaTransform } from ${JSON.stringify(descriptor.entrypoint)}; + +export const transformMedia = _createMediaTransform(${JSON.stringify(descriptor.config)}); +export const transformableContentTypes = ${JSON.stringify(descriptor.contentTypes ?? null)}; +`; +} + /** * Generates the auth virtual module. * Statically imports the configured auth provider. diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 982877ada..86e809329 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -36,6 +36,8 @@ import { RESOLVED_VIRTUAL_AUTH_PROVIDERS_ID, VIRTUAL_MEDIA_PROVIDERS_ID, RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID, + VIRTUAL_MEDIA_TRANSFORM_ID, + RESOLVED_VIRTUAL_MEDIA_TRANSFORM_ID, VIRTUAL_BLOCK_COMPONENTS_ID, RESOLVED_VIRTUAL_BLOCK_COMPONENTS_ID, VIRTUAL_SEED_ID, @@ -47,6 +49,7 @@ import { generateConfigModule, generateDialectModule, generateStorageModule, + generateMediaTransformModule, generateAuthModule, generateAuthProvidersModule, generatePluginsModule, @@ -194,6 +197,9 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin { if (id === VIRTUAL_MEDIA_PROVIDERS_ID) { return RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID; } + if (id === VIRTUAL_MEDIA_TRANSFORM_ID) { + return RESOLVED_VIRTUAL_MEDIA_TRANSFORM_ID; + } if (id === VIRTUAL_BLOCK_COMPONENTS_ID) { return RESOLVED_VIRTUAL_BLOCK_COMPONENTS_ID; } @@ -257,6 +263,9 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin { if (id === RESOLVED_VIRTUAL_MEDIA_PROVIDERS_ID) { return generateMediaProvidersModule(resolvedConfig.mediaProviders ?? []); } + if (id === RESOLVED_VIRTUAL_MEDIA_TRANSFORM_ID) { + return generateMediaTransformModule(resolvedConfig.mediaTransforms); + } // Generate block components module (plugin rendering components for PortableText) if (id === RESOLVED_VIRTUAL_BLOCK_COMPONENTS_ID) { return generateBlockComponentsModule(pluginDescriptors); diff --git a/packages/core/src/astro/routes/api/media/file/[...key].ts b/packages/core/src/astro/routes/api/media/file/[...key].ts index 4dc231de0..c406dd0fe 100644 --- a/packages/core/src/astro/routes/api/media/file/[...key].ts +++ b/packages/core/src/astro/routes/api/media/file/[...key].ts @@ -5,6 +5,7 @@ */ import type { APIRoute } from "astro"; +import { transformableContentTypes, transformMedia } from "virtual:emdash/media-transform"; import { apiError, handleError } from "#api/error.js"; @@ -28,7 +29,13 @@ const SAFE_INLINE_TYPES = new Set([ "audio/ogg", ]); -export const GET: APIRoute = async ({ params, locals }) => { +function shouldTransform(contentType: string): boolean { + if (!transformMedia) return false; + if (!transformableContentTypes) return true; + return transformableContentTypes.includes(contentType); +} + +export const GET: APIRoute = async ({ params, locals, request }) => { const { key } = params; const { emdash } = locals; @@ -42,30 +49,63 @@ export const GET: APIRoute = async ({ params, locals }) => { try { const result = await emdash.storage.download(key); + let body: BodyInit = result.body; + let contentType = result.contentType; + let contentLength: number | undefined = result.size; + let transformHeaders: Record | undefined; + + if (shouldTransform(result.contentType)) { + const bodyBytes = await new Response(result.body).arrayBuffer(); + body = bodyBytes; + + try { + const transformed = await transformMedia?.({ + body: bodyBytes, + contentType: result.contentType, + size: result.size, + key, + request, + }); + if (transformed) { + body = transformed.body; + contentType = transformed.contentType; + contentLength = transformed.contentLength; + transformHeaders = transformed.headers; + } + } catch (error) { + console.error({ + event: "emdash_media_transform_failed", + key, + contentType: result.contentType, + error: error instanceof Error ? error.message : String(error), + }); + } + } const headers: Record = { - "Content-Type": result.contentType, + "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable", "X-Content-Type-Options": "nosniff", // Sandbox CSP on all user-uploaded content — prevents script execution // even for SVGs navigated to directly or content types that support scripting. "Content-Security-Policy": "sandbox; default-src 'none'; img-src 'self'; style-src 'unsafe-inline'", + ...transformHeaders, }; - if (result.size) { - headers["Content-Length"] = String(result.size); + if (contentLength) { + headers["Content-Length"] = String(contentLength); } // Safe image/media types can render inline; everything else (SVG, PDF, // HTML, JS, etc.) must be downloaded to prevent stored XSS. - if (SAFE_INLINE_TYPES.has(result.contentType)) { + if (SAFE_INLINE_TYPES.has(contentType)) { headers["Content-Disposition"] = "inline"; } else { headers["Content-Disposition"] = "attachment"; } - return new Response(result.body, { status: 200, headers }); + return new Response(body, { status: 200, headers }); } catch (error) { // Check if it's a "not found" error if ( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a64261226..cdf87dbe7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -190,6 +190,13 @@ export type { CreateStorageFn, } from "./storage/types.js"; export { EmDashStorageError } from "./storage/types.js"; +export type { + CreateMediaTransformFn, + MediaTransform, + MediaTransformDescriptor, + MediaTransformInput, + MediaTransformOutput, +} from "./media/transform.js"; // Plugin system export { diff --git a/packages/core/src/media/index.ts b/packages/core/src/media/index.ts index 2879a072c..e8df8e1ce 100644 --- a/packages/core/src/media/index.ts +++ b/packages/core/src/media/index.ts @@ -24,6 +24,14 @@ export type { ThumbnailOptions, } from "./types.js"; +export type { + CreateMediaTransformFn, + MediaTransform, + MediaTransformDescriptor, + MediaTransformInput, + MediaTransformOutput, +} from "./transform.js"; + export { mediaItemToValue } from "./types.js"; export { normalizeMediaValue } from "./normalize.js"; export { generatePlaceholder, type PlaceholderData } from "./placeholder.js"; diff --git a/packages/core/src/media/transform.ts b/packages/core/src/media/transform.ts new file mode 100644 index 000000000..af1b9e50a --- /dev/null +++ b/packages/core/src/media/transform.ts @@ -0,0 +1,31 @@ +export interface MediaTransformDescriptor { + /** Module path exporting createMediaTransform. */ + entrypoint: string; + /** Serializable config passed to createMediaTransform at runtime. */ + config: unknown; + /** Content types that should be buffered and passed to the transformer. */ + contentTypes?: string[]; +} + +export interface MediaTransformInput { + body: ArrayBuffer; + contentType: string; + size?: number; + key: string; + request: Request; +} + +export interface MediaTransformOutput { + body: BodyInit; + contentType: string; + contentLength?: number; + headers?: Record; +} + +export type MediaTransform = ( + input: MediaTransformInput, +) => Promise; + +export type CreateMediaTransformFn> = ( + config: TConfig, +) => MediaTransform; diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index fc1429668..cc03367d0 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -11,12 +11,14 @@ declare module "virtual:emdash/config" { AuthDescriptor, AuthProviderDescriptor, DatabaseDescriptor, + MediaTransformDescriptor, StorageDescriptor, } from "./index.js"; interface VirtualConfig { database?: DatabaseDescriptor; storage?: StorageDescriptor; + mediaTransforms?: MediaTransformDescriptor; auth?: AuthDescriptor; authProviders?: AuthProviderDescriptor[]; i18n?: I18nConfig | null; @@ -86,6 +88,13 @@ declare module "virtual:emdash/auth" { export const authenticate: ((request: Request, config: unknown) => Promise) | null; } +declare module "virtual:emdash/media-transform" { + import type { MediaTransform } from "./media/transform.js"; + + export const transformMedia: MediaTransform | undefined; + export const transformableContentTypes: string[] | null | undefined; +} + declare module "virtual:emdash/plugins" { import type { ResolvedPlugin } from "./plugins/types.js"; diff --git a/packages/core/tests/unit/astro/integration/virtual-modules.test.ts b/packages/core/tests/unit/astro/integration/virtual-modules.test.ts index eaf8ef993..9d51a8359 100644 --- a/packages/core/tests/unit/astro/integration/virtual-modules.test.ts +++ b/packages/core/tests/unit/astro/integration/virtual-modules.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { generateConfigModule, generateDialectModule, + generateMediaTransformModule, generateSeedModule, } from "../../../../src/astro/integration/virtual-modules.js"; @@ -67,6 +68,28 @@ describe("generateDialectModule", () => { }); }); +describe("generateMediaTransformModule", () => { + it("emits undefined transform exports when no adapter is configured", () => { + const out = generateMediaTransformModule(); + expect(out).toContain("export const transformMedia = undefined"); + expect(out).toContain("export const transformableContentTypes = undefined"); + }); + + it("imports and instantiates the configured adapter", () => { + const out = generateMediaTransformModule({ + entrypoint: "@emdash-cms/cloudflare/media/image-transforms", + config: { binding: "IMAGES", timeoutMs: 5000 }, + contentTypes: ["image/png"], + }); + + expect(out).toContain( + `import { createMediaTransform as _createMediaTransform } from "@emdash-cms/cloudflare/media/image-transforms"`, + ); + expect(out).toContain(`"binding":"IMAGES"`); + expect(out).toContain(`export const transformableContentTypes = ["image/png"]`); + }); +}); + describe("generateSeedModule", () => { let projectRoot: string; diff --git a/packages/core/tests/unit/astro/media-file-transform.test.ts b/packages/core/tests/unit/astro/media-file-transform.test.ts new file mode 100644 index 000000000..53be16908 --- /dev/null +++ b/packages/core/tests/unit/astro/media-file-transform.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadRoute( + transformMedia: unknown, + transformableContentTypes: string[] | null | undefined, +) { + vi.resetModules(); + vi.doMock( + "virtual:emdash/media-transform", + () => ({ transformMedia, transformableContentTypes }), + { virtual: true }, + ); + return import("../../../src/astro/routes/api/media/file/[...key].js"); +} + +function mediaContext(contentType = "image/png") { + const download = vi.fn().mockResolvedValue({ + body: new Uint8Array([1, 2, 3]), + contentType, + size: 3, + }); + + return { + context: { + params: { key: "image.png" }, + request: new Request("https://example.com/_emdash/api/media/file/image.png?width=800", { + headers: { accept: "image/avif,image/webp" }, + }), + locals: { + emdash: { + storage: { download }, + }, + }, + }, + download, + }; +} + +describe("media file route transforms", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock("virtual:emdash/media-transform"); + }); + + it("returns transformed media when the configured adapter succeeds", async () => { + const transformMedia = vi.fn().mockResolvedValue({ + body: new Uint8Array([9, 8]), + contentType: "image/webp", + headers: { Vary: "Accept" }, + }); + const { GET } = await loadRoute(transformMedia, ["image/png"]); + const { context } = mediaContext(); + + const response = await GET(context as Parameters[0]); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/webp"); + expect(response.headers.get("Vary")).toBe("Accept"); + expect(response.headers.get("Content-Length")).toBeNull(); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([9, 8])); + expect(transformMedia).toHaveBeenCalledWith( + expect.objectContaining({ + contentType: "image/png", + key: "image.png", + size: 3, + }), + ); + }); + + it("serves original media when the adapter fails", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const transformMedia = vi.fn().mockRejectedValue(new Error("transform unavailable")); + const { GET } = await loadRoute(transformMedia, ["image/png"]); + const { context } = mediaContext(); + + const response = await GET(context as Parameters[0]); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/png"); + expect(response.headers.get("Vary")).toBeNull(); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + event: "emdash_media_transform_failed", + key: "image.png", + error: "transform unavailable", + }), + ); + }); + + it("skips the adapter for non-matching content types", async () => { + const transformMedia = vi.fn(); + const { GET } = await loadRoute(transformMedia, ["image/png"]); + const { context } = mediaContext("image/gif"); + + const response = await GET(context as Parameters[0]); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/gif"); + expect(transformMedia).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 1fb9c0c69..310ce04d5 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -11,6 +11,8 @@ const virtualStubs: Record = { // (e.g. `virtualConfig?.i18n?.defaultLocale`) don't blow up on import. // Tests that need real config still `vi.mock(...)` their own. "virtual:emdash/config": "export default {};", + "virtual:emdash/media-transform": + "export const transformMedia = undefined; export const transformableContentTypes = undefined;", }; export default defineConfig({ From 2fa2930a76f7369b8526b346d6cca1a156ef0a31 Mon Sep 17 00:00:00 2001 From: mvm Date: Mon, 8 Jun 2026 18:12:40 -0500 Subject: [PATCH 2/6] fix(cloudflare): narrow Images binding safely --- packages/cloudflare/src/media/image-transforms-runtime.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/src/media/image-transforms-runtime.ts b/packages/cloudflare/src/media/image-transforms-runtime.ts index de06cb23d..ad7011170 100644 --- a/packages/cloudflare/src/media/image-transforms-runtime.ts +++ b/packages/cloudflare/src/media/image-transforms-runtime.ts @@ -34,8 +34,12 @@ async function withTimeout(promise: Promise, timeoutMs: number): Promise)[binding]; - if (!value || typeof (value as { input?: unknown }).input !== "function") return undefined; - return value as ImagesBinding; + 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 = ( From 4c8012ab28dd1363cd65c409a24d39b3788b3a40 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Mon, 8 Jun 2026 23:13:05 +0000 Subject: [PATCH 3/6] style: format --- packages/cloudflare/src/media/image-transforms-runtime.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/media/image-transforms-runtime.ts b/packages/cloudflare/src/media/image-transforms-runtime.ts index ad7011170..e4433972e 100644 --- a/packages/cloudflare/src/media/image-transforms-runtime.ts +++ b/packages/cloudflare/src/media/image-transforms-runtime.ts @@ -39,7 +39,12 @@ function getImagesBinding(binding: string): ImagesBinding | undefined { } function isImagesBinding(value: unknown): value is ImagesBinding { - return typeof value === "object" && value !== null && "input" in value && typeof value.input === "function"; + return ( + typeof value === "object" && + value !== null && + "input" in value && + typeof value.input === "function" + ); } export const createMediaTransform: CreateMediaTransformFn = ( From 5cdf26326b25858d89402abe65912b978891b9c3 Mon Sep 17 00:00:00 2001 From: mvm Date: Mon, 8 Jun 2026 18:24:22 -0500 Subject: [PATCH 4/6] chore: rerun CI From 29640dcc470a3e76fa34fe06b95e612d2cb55481 Mon Sep 17 00:00:00 2001 From: mvm Date: Mon, 8 Jun 2026 21:07:58 -0500 Subject: [PATCH 5/6] fix(media): stream image transform input --- .../src/media/image-transforms-runtime.ts | 15 ++- .../tests/media/image-transforms.test.ts | 123 +++++++++++++++++- .../astro/routes/api/media/file/[...key].ts | 6 +- packages/core/src/media/transform.ts | 4 +- .../unit/astro/media-file-transform.test.ts | 9 +- 5 files changed, 145 insertions(+), 12 deletions(-) diff --git a/packages/cloudflare/src/media/image-transforms-runtime.ts b/packages/cloudflare/src/media/image-transforms-runtime.ts index e4433972e..97d85c02c 100644 --- a/packages/cloudflare/src/media/image-transforms-runtime.ts +++ b/packages/cloudflare/src/media/image-transforms-runtime.ts @@ -10,7 +10,15 @@ const DEFAULT_QUALITY = 85; function imageFormatForRequest(request: Request): "image/avif" | "image/webp" { const accept = request.headers.get("accept") || ""; - return accept.includes("image/avif") ? "image/avif" : "image/webp"; + 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 { @@ -59,12 +67,9 @@ export const createMediaTransform: CreateMediaTransformFn = {}; + +vi.mock("cloudflare:workers", () => ({ + get env() { + return testEnv; + }, +})); + +async function loadRuntime() { + vi.resetModules(); + return import("../../src/media/image-transforms-runtime.js"); +} + +function stream(bytes = [1, 2, 3]): ReadableStream { + const body = new Response(new Uint8Array(bytes)).body; + if (!body) throw new Error("Expected response body"); + return body; +} + describe("cloudflareImageTransforms", () => { + afterEach(() => { + testEnv = {}; + vi.useRealTimers(); + }); + it("returns a media transform descriptor for the Cloudflare runtime", () => { const descriptor = cloudflareImageTransforms({ binding: "IMAGES", @@ -27,4 +51,101 @@ describe("cloudflareImageTransforms", () => { contentTypes: CLOUDFLARE_IMAGE_TRANSFORM_TYPES, }); }); + + it("returns null when the configured Images binding is absent", async () => { + const { createMediaTransform } = await loadRuntime(); + const transform = createMediaTransform({ binding: "IMAGES" }); + + await expect( + transform({ + body: stream(), + contentType: "image/png", + key: "image.png", + request: new Request("https://example.com/image.png"), + }), + ).resolves.toBeNull(); + }); + + it("passes width, format, and quality options to the Images binding", async () => { + const outputStream = stream([9, 8, 7]); + const output = vi.fn().mockResolvedValue({ + image: () => outputStream, + contentType: () => "image/avif", + }); + const transformStep = vi.fn(() => ({ output })); + const input = vi.fn(() => ({ transform: transformStep })); + testEnv = { IMAGES: { input } }; + + const { createMediaTransform } = await loadRuntime(); + const transform = createMediaTransform({ + binding: "IMAGES", + defaultWidth: 1200, + maxWidth: 2000, + quality: 80, + }); + + const result = await transform({ + body: stream(), + contentType: "image/png", + key: "image.png", + request: new Request("https://example.com/image.png?width=5000", { + headers: { accept: "image/avif,image/webp" }, + }), + }); + + expect(input).toHaveBeenCalledWith(expect.any(ReadableStream)); + expect(transformStep).toHaveBeenCalledWith({ fit: "scale-down", width: 2000 }); + expect(output).toHaveBeenCalledWith({ format: "image/avif", quality: 80 }); + expect(result).toEqual({ + body: outputStream, + contentType: "image/avif", + headers: { Vary: "Accept" }, + }); + }); + + it("falls back to WebP when AVIF is explicitly rejected", async () => { + const output = vi.fn().mockResolvedValue({ + image: () => stream([4]), + contentType: () => "image/webp", + }); + const transformStep = vi.fn(() => ({ output })); + testEnv = { IMAGES: { input: vi.fn(() => ({ transform: transformStep })) } }; + + const { createMediaTransform } = await loadRuntime(); + const transform = createMediaTransform({ binding: "IMAGES" }); + + await transform({ + body: stream(), + contentType: "image/png", + key: "image.png", + request: new Request("https://example.com/image.png", { + headers: { accept: "image/webp,image/avif;q=0" }, + }), + }); + + expect(output).toHaveBeenCalledWith({ format: "image/webp", quality: 85 }); + }); + + it("rejects when the Images transform exceeds the configured timeout", async () => { + vi.useFakeTimers(); + const output = vi.fn(() => new Promise(() => undefined)); + testEnv = { + IMAGES: { + input: vi.fn(() => ({ transform: vi.fn(() => ({ output })) })), + }, + }; + + const { createMediaTransform } = await loadRuntime(); + const transform = createMediaTransform({ binding: "IMAGES", timeoutMs: 10 }); + const promise = transform({ + body: stream(), + contentType: "image/png", + key: "image.png", + request: new Request("https://example.com/image.png"), + }); + const expectation = expect(promise).rejects.toThrow("Image transform timed out"); + + await vi.advanceTimersByTimeAsync(10); + await expectation; + }); }); diff --git a/packages/core/src/astro/routes/api/media/file/[...key].ts b/packages/core/src/astro/routes/api/media/file/[...key].ts index c406dd0fe..6c85fce12 100644 --- a/packages/core/src/astro/routes/api/media/file/[...key].ts +++ b/packages/core/src/astro/routes/api/media/file/[...key].ts @@ -55,12 +55,12 @@ export const GET: APIRoute = async ({ params, locals, request }) => { let transformHeaders: Record | undefined; if (shouldTransform(result.contentType)) { - const bodyBytes = await new Response(result.body).arrayBuffer(); - body = bodyBytes; + const [transformBody, fallbackBody] = result.body.tee(); + body = fallbackBody; try { const transformed = await transformMedia?.({ - body: bodyBytes, + body: transformBody, contentType: result.contentType, size: result.size, key, diff --git a/packages/core/src/media/transform.ts b/packages/core/src/media/transform.ts index af1b9e50a..20cc0776c 100644 --- a/packages/core/src/media/transform.ts +++ b/packages/core/src/media/transform.ts @@ -3,12 +3,12 @@ export interface MediaTransformDescriptor { entrypoint: string; /** Serializable config passed to createMediaTransform at runtime. */ config: unknown; - /** Content types that should be buffered and passed to the transformer. */ + /** Content types that should be passed to the transformer. */ contentTypes?: string[]; } export interface MediaTransformInput { - body: ArrayBuffer; + body: ReadableStream; contentType: string; size?: number; key: string; diff --git a/packages/core/tests/unit/astro/media-file-transform.test.ts b/packages/core/tests/unit/astro/media-file-transform.test.ts index 53be16908..1059a6ce3 100644 --- a/packages/core/tests/unit/astro/media-file-transform.test.ts +++ b/packages/core/tests/unit/astro/media-file-transform.test.ts @@ -15,7 +15,7 @@ async function loadRoute( function mediaContext(contentType = "image/png") { const download = vi.fn().mockResolvedValue({ - body: new Uint8Array([1, 2, 3]), + body: stream([1, 2, 3]), contentType, size: 3, }); @@ -36,6 +36,12 @@ function mediaContext(contentType = "image/png") { }; } +function stream(bytes: number[]): ReadableStream { + const body = new Response(new Uint8Array(bytes)).body; + if (!body) throw new Error("Expected response body"); + return body; +} + describe("media file route transforms", () => { afterEach(() => { vi.restoreAllMocks(); @@ -60,6 +66,7 @@ describe("media file route transforms", () => { expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([9, 8])); expect(transformMedia).toHaveBeenCalledWith( expect.objectContaining({ + body: expect.any(ReadableStream), contentType: "image/png", key: "image.png", size: 3, From 6ed3a7e7663a49a45b4cda8e362692de192652ca Mon Sep 17 00:00:00 2001 From: mvm Date: Mon, 8 Jun 2026 21:31:52 -0500 Subject: [PATCH 6/6] test(e2e): dismiss onboarding after loading --- e2e/fixtures/admin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/fixtures/admin.ts b/e2e/fixtures/admin.ts index 18351a412..4123659a7 100644 --- a/e2e/fixtures/admin.ts +++ b/e2e/fixtures/admin.ts @@ -155,6 +155,7 @@ export class AdminPage { .locator(".animate-spin") .waitFor({ state: "hidden", timeout: 10000 }) .catch(() => {}); + await this.dismissOnboardingModal(); } // ============================================