diff --git a/.changeset/image-transform-binding.md b/.changeset/image-transform-binding.md new file mode 100644 index 0000000000..f5f7ffbce6 --- /dev/null +++ b/.changeset/image-transform-binding.md @@ -0,0 +1,21 @@ +--- +"emdash": minor +"@emdash-cms/cloudflare": minor +--- + +Adds a binding-based image transform service so responsive images work behind Cloudflare Access. + +Previously, resizing R2/local media routed through Astro's `/_image` endpoint, which made the server fetch the media's own URL to load the source bytes — a self-referential request that fails when the site is behind Cloudflare Access or has loopback fetches disabled, surfacing as 404s on transformed images. EmDash now serves transforms from `/_emdash/api/media/transform/{key}`, reading bytes straight from the storage adapter and resizing them with a configured transformer, so no server-side fetch of the media URL is made. + +To enable it on Cloudflare, add an `IMAGES` binding to your wrangler config and wire it up: + +```ts +import { imageBinding } from "@emdash-cms/cloudflare"; + +emdash({ + storage: r2({ binding: "MEDIA" }), + images: imageBinding({ binding: "IMAGES" }), +}); +``` + +When `images` is not configured, behavior is unchanged. diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 71a8f30a13..02d54d9b50 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -57,6 +57,10 @@ "types": "./dist/media/stream-runtime.d.mts", "default": "./dist/media/stream-runtime.mjs" }, + "./media/transform-runtime": { + "types": "./dist/media/transform-runtime.d.mts", + "default": "./dist/media/transform-runtime.mjs" + }, "./cache": { "types": "./dist/cache/runtime.d.mts", "default": "./dist/cache/runtime.mjs" diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 0f56fe8233..49106cd13d 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, + ImageServiceDescriptor, + StorageDescriptor, +} from "emdash"; import type { PreviewDOConfig } from "./db/do-types.js"; @@ -290,6 +295,48 @@ export function access(config: AccessConfig): AuthDescriptor { }; } +/** + * Cloudflare Images binding configuration. + */ +export interface ImageBindingConfig { + /** + * Name of the Images binding in wrangler config. + * @default "IMAGES" + */ + binding?: string; +} + +/** + * Cloudflare Images binding adapter for request-time media transforms. + * + * Wires EmDash's transform route (`/_emdash/api/media/transform/{key}`) to the + * Cloudflare `IMAGES` binding. The route reads source bytes straight from the + * storage adapter (R2) and resizes them with the binding — there is no + * server-side fetch of the media URL, so responsive images work even when the + * site is behind Cloudflare Access or loopback fetches are disabled. + * + * Requires an Images binding in wrangler config: + * ```jsonc + * { "images": { "binding": "IMAGES" } } + * ``` + * + * @example + * ```ts + * import { imageBinding } from "@emdash-cms/cloudflare"; + * + * emdash({ + * storage: r2({ binding: "MEDIA" }), + * images: imageBinding({ binding: "IMAGES" }), + * }) + * ``` + */ +export function imageBinding(config: ImageBindingConfig = {}): ImageServiceDescriptor { + return { + entrypoint: "@emdash-cms/cloudflare/media/transform-runtime", + config: { binding: config.binding ?? "IMAGES" }, + }; +} + /** * Cloudflare Worker Loader sandbox adapter * diff --git a/packages/cloudflare/src/media/transform-runtime.ts b/packages/cloudflare/src/media/transform-runtime.ts new file mode 100644 index 0000000000..e3c196d45c --- /dev/null +++ b/packages/cloudflare/src/media/transform-runtime.ts @@ -0,0 +1,68 @@ +/** + * Cloudflare Images binding — image transformer RUNTIME ENTRY. + * + * Resizes media source bytes with the Cloudflare `IMAGES` binding. Imported at + * runtime via the `images` descriptor (see `imageBinding()`); the EmDash + * transform route reads bytes from storage and passes them here, so no public + * fetch of the media URL is required. + * + * This module imports from `cloudflare:workers` to access the binding. Do NOT + * import it at config time — use `imageBinding()` from `@emdash-cms/cloudflare`. + */ + +import { env } from "cloudflare:workers"; +import type { CreateImageTransformerFn, ImageTransformFormat, TransformedImage } from "emdash"; + +/** Map EmDash's short format names to the MIME types the binding expects. */ +const FORMAT_MIME: Record = { + webp: "image/webp", + avif: "image/avif", + jpeg: "image/jpeg", + png: "image/png", +}; + +/** + * Create the Cloudflare Images binding transformer. + * + * Resolves the binding by name from the Worker env at request time. + */ +export const createImageTransformer: CreateImageTransformerFn = (config) => { + const bindingName = + typeof config.binding === "string" && config.binding ? config.binding : "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 + const images = (env as Record)[bindingName] as ImagesBinding | undefined; + + if (!images) { + throw new Error( + `Cloudflare Images binding "${bindingName}" not found. ` + + `Add it to wrangler.jsonc:\n` + + `{\n "images": {\n "binding": "${bindingName}"\n }\n}`, + ); + } + + return { + async transform(input, options): Promise { + const mime = FORMAT_MIME[options.format ?? "webp"] ?? "image/webp"; + + const transform: ImageTransform = {}; + if (options.width) transform.width = options.width; + if (options.height) transform.height = options.height; + + const output: ImageOutputOptions = { format: mime }; + if (options.quality) output.quality = options.quality; + + const result = await images.input(input).transform(transform).output(output); + const response = result.response(); + if (!response.body) { + throw new Error("Cloudflare Images transform produced an empty body"); + } + + return { + body: response.body, + contentType: response.headers.get("Content-Type") ?? mime, + }; + }, + }; +}; diff --git a/packages/cloudflare/tsdown.config.ts b/packages/cloudflare/tsdown.config.ts index a067e41fe0..a836fffd2f 100644 --- a/packages/cloudflare/tsdown.config.ts +++ b/packages/cloudflare/tsdown.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ // Media provider runtimes "src/media/images-runtime.ts", "src/media/stream-runtime.ts", + // Image transform binding runtime + "src/media/transform-runtime.ts", // Cache provider "src/cache/runtime.ts", "src/cache/config.ts", diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index fc1336425c..4b15a59e73 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -284,6 +284,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { const serializableConfig: Record = { database: resolvedConfig.database, storage: resolvedConfig.storage, + images: resolvedConfig.images, auth: resolvedConfig.auth, authProviders: resolvedConfig.authProviders, marketplace: resolvedConfig.marketplace, diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 691f862a06..32f333f712 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -175,6 +175,11 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void { entrypoint: resolveRoute("api/media/file/[...key].ts"), }); + injectRoute({ + pattern: "/_emdash/api/media/transform/[...key]", + entrypoint: resolveRoute("api/media/transform/[...key].ts"), + }); + injectRoute({ pattern: "/_emdash/api/media/[id]", entrypoint: resolveRoute("api/media/[id].ts"), diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 61d504a9c2..4591fc7bfe 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 { ImageServiceDescriptor } from "../../media/image-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,24 @@ export interface EmDashConfig { * Storage configuration (for media) */ storage?: StorageDescriptor; + /** + * Image transform service (for resizing same-origin media at request time). + * + * Serves binding-based transforms from `/_emdash/api/media/transform/{key}`, + * reading source bytes straight from the storage adapter — no server-side + * fetch of the media URL, so it works behind Cloudflare Access and with + * loopback fetches disabled. + * + * @example + * ```ts + * import { imageBinding } from "@emdash-cms/cloudflare"; + * + * emdash({ + * images: imageBinding({ binding: "IMAGES" }), + * }) + * ``` + */ + images?: ImageServiceDescriptor; /** * 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 812d393bee..b50c866f4a 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -33,6 +33,9 @@ export const RESOLVED_VIRTUAL_DIALECT_ID = "\0" + VIRTUAL_DIALECT_ID; export const VIRTUAL_STORAGE_ID = "virtual:emdash/storage"; export const RESOLVED_VIRTUAL_STORAGE_ID = "\0" + VIRTUAL_STORAGE_ID; +export const VIRTUAL_IMAGES_ID = "virtual:emdash/images"; +export const RESOLVED_VIRTUAL_IMAGES_ID = "\0" + VIRTUAL_IMAGES_ID; + export const VIRTUAL_ADMIN_REGISTRY_ID = "virtual:emdash/admin-registry"; export const RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID = "\0" + VIRTUAL_ADMIN_REGISTRY_ID; @@ -128,6 +131,22 @@ export const createStorage = _createStorage; `; } +/** + * Generates the image-transformer virtual module. + * Statically imports the configured image service (e.g. the Cloudflare IMAGES + * binding). Exports `undefined` when no image service is configured, in which + * case the transform route streams the original bytes through unchanged. + */ +export function generateImagesModule(imagesEntrypoint?: string): string { + if (!imagesEntrypoint) { + return `export const createImageTransformer = undefined;`; + } + return ` +import { createImageTransformer as _createImageTransformer } from "${imagesEntrypoint}"; +export const createImageTransformer = _createImageTransformer; +`; +} + /** * 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 805442ec21..a3780db88b 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -22,6 +22,8 @@ import { RESOLVED_VIRTUAL_DIALECT_ID, VIRTUAL_STORAGE_ID, RESOLVED_VIRTUAL_STORAGE_ID, + VIRTUAL_IMAGES_ID, + RESOLVED_VIRTUAL_IMAGES_ID, VIRTUAL_ADMIN_REGISTRY_ID, RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID, VIRTUAL_PLUGINS_ID, @@ -50,6 +52,7 @@ import { generateConfigModule, generateDialectModule, generateStorageModule, + generateImagesModule, generateAuthModule, generateAuthProvidersModule, generatePluginsModule, @@ -176,6 +179,9 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin { if (id === VIRTUAL_STORAGE_ID) { return RESOLVED_VIRTUAL_STORAGE_ID; } + if (id === VIRTUAL_IMAGES_ID) { + return RESOLVED_VIRTUAL_IMAGES_ID; + } if (id === VIRTUAL_ADMIN_REGISTRY_ID) { return RESOLVED_VIRTUAL_ADMIN_REGISTRY_ID; } @@ -227,6 +233,13 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin { if (id === RESOLVED_VIRTUAL_STORAGE_ID) { return generateStorageModule(resolvedConfig.storage?.entrypoint); } + + // Generate a module that statically imports the configured image + // transformer (e.g. the Cloudflare IMAGES binding), or exports + // undefined when none is configured. + if (id === RESOLVED_VIRTUAL_IMAGES_ID) { + return generateImagesModule(resolvedConfig.images?.entrypoint); + } // Generate plugins module that imports and instantiates all plugins if (id === RESOLVED_VIRTUAL_PLUGINS_ID) { return generatePluginsModule(pluginDescriptors); diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 4832db28ae..ff755e59ee 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -17,6 +17,8 @@ import { } from "virtual:emdash/dialect"; import type { RequestScopedDbOpts } from "virtual:emdash/dialect"; // @ts-ignore - virtual module +import { createImageTransformer as virtualCreateImageTransformer } from "virtual:emdash/images"; +// @ts-ignore - virtual module import { mediaProviders as virtualMediaProviders } from "virtual:emdash/media-providers"; // @ts-ignore - virtual module import { plugins as virtualPlugins } from "virtual:emdash/plugins"; @@ -45,6 +47,7 @@ import { } from "../emdash-runtime.js"; import { setI18nConfig } from "../i18n/config.js"; import type { Database, Storage } from "../index.js"; +import type { CreateImageTransformerFn, TransformImageFn } from "../media/image-transform.js"; import { createPublicMediaUrlResolver } from "../media/url.js"; import type { SandboxRunner } from "../plugins/sandbox/types.js"; import type { ResolvedPlugin } from "../plugins/types.js"; @@ -162,6 +165,38 @@ function getPlugins(): ResolvedPlugin[] { return (virtualPlugins as ResolvedPlugin[]) || []; } +// Image transformer is stateless and configured once per deployment; build it +// lazily on first use and reuse it. `null` records "tried and unavailable" so +// a missing binding doesn't re-throw and re-log on every request. +let _transformImage: TransformImageFn | null | undefined; + +/** + * Resolve the request-time image transform function from the virtual module and + * config. Returns `undefined` when no image service is configured or the + * adapter fails to initialize (e.g. a missing binding), in which case the + * transform route streams the original bytes through unchanged. + */ +function getTransformImage(config: EmDashConfig): TransformImageFn | undefined { + if (_transformImage !== undefined) return _transformImage ?? undefined; + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- virtual module import is untyped (@ts-ignore above) + const create = virtualCreateImageTransformer as CreateImageTransformerFn | undefined; + const descriptor = config.images; + if (!create || !descriptor) { + _transformImage = null; + return undefined; + } + try { + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- descriptor.config is a serialized `unknown`; the adapter validates its own shape + const transformer = create((descriptor.config ?? {}) as Record); + _transformImage = transformer.transform.bind(transformer); + return _transformImage; + } catch (error) { + console.error("[emdash] image transformer init failed:", error); + _transformImage = null; + return undefined; + } +} + /** * Build runtime dependencies from virtual modules */ @@ -472,6 +507,7 @@ export const onRequest = defineMiddleware(async (context, next) => { collectPageMetadata: runtime.collectPageMetadata.bind(runtime), collectPageFragments: runtime.collectPageFragments.bind(runtime), getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage), + transformImage: getTransformImage(config), } as EmDashHandlers; } catch { // Non-fatal — EmDashHead will fall back to base SEO contributions @@ -618,6 +654,7 @@ export const onRequest = defineMiddleware(async (context, next) => { storage: runtime.storage, db: runtime.db, getPublicMediaUrl: createPublicMediaUrlResolver(runtime.storage), + transformImage: getTransformImage(config), hooks: runtime.hooks, email: runtime.email, configuredPlugins: runtime.configuredPlugins, diff --git a/packages/core/src/astro/routes/api/media/transform/[...key].ts b/packages/core/src/astro/routes/api/media/transform/[...key].ts new file mode 100644 index 0000000000..7ab2d51164 --- /dev/null +++ b/packages/core/src/astro/routes/api/media/transform/[...key].ts @@ -0,0 +1,95 @@ +/** + * Transform and serve uploaded media files. + * + * GET /_emdash/api/media/transform/:key?w=&h=&f=&q= + * + * Reads the source bytes straight from the storage adapter (e.g. the R2 + * binding) and resizes them with the configured image transformer (the + * Cloudflare `IMAGES` binding). Unlike Astro's `/_image` endpoint, this never + * fetches the source over HTTP, so it works when the origin is gated behind + * Cloudflare Access or when loopback fetches are disabled. + * + * When no transformer is configured (e.g. on Node without a binding), the + * original bytes are streamed through unchanged so stale URLs still resolve. + */ + +import type { APIRoute } from "astro"; + +import { apiError, handleError } from "#api/error.js"; + +import { isSafeTransformKey, parseTransformParams } from "../../../../../media/image-transform.js"; + +export const prerender = false; + +/** Long-lived immutable cache — transform output is deterministic per key+params. */ +const IMMUTABLE_CACHE = "public, max-age=31536000, immutable"; + +function isNotFound(error: unknown): boolean { + return ( + error instanceof Error && + (error.message.includes("not found") || error.message.includes("NOT_FOUND")) + ); +} + +export const GET: APIRoute = async ({ params, url, locals }) => { + const { key } = params; + const { emdash } = locals; + + if (!key) { + return apiError("NOT_FOUND", "File not found", 404); + } + + // The transform route only serves flat storage keys; reject anything with + // slashes/traversal so it can't reroute or traverse on the backend. + if (!isSafeTransformKey(key)) { + return apiError("NOT_FOUND", "File not found", 404); + } + + if (!emdash?.storage) { + return apiError("NOT_CONFIGURED", "Storage not configured", 500); + } + + const parsed = parseTransformParams(url.searchParams); + if (!parsed.ok) { + return apiError("VALIDATION_ERROR", parsed.message, 400); + } + + try { + const source = await emdash.storage.download(key); + + // Only raster images can be transformed. Refuse anything else rather than + // feed it to the binding (SVG/PDF/etc. would error or be unsafe). + if (!source.contentType.startsWith("image/")) { + return apiError("VALIDATION_ERROR", "Source is not a transformable image", 400); + } + + // No transformer configured (Node, or no IMAGES binding): stream the + // original through so the URL still resolves to a valid image. + if (!emdash.transformImage) { + return new Response(source.body, { + status: 200, + headers: { + "Content-Type": source.contentType, + "Cache-Control": IMMUTABLE_CACHE, + "X-Content-Type-Options": "nosniff", + }, + }); + } + + const transformed = await emdash.transformImage(source.body, parsed.options); + + return new Response(transformed.body, { + status: 200, + headers: { + "Content-Type": transformed.contentType, + "Cache-Control": IMMUTABLE_CACHE, + "X-Content-Type-Options": "nosniff", + }, + }); + } catch (error) { + if (isNotFound(error)) { + return apiError("NOT_FOUND", "File not found", 404); + } + return handleError(error, "Failed to transform image", "IMAGE_TRANSFORM_ERROR"); + } +}; diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index d4a10544fc..47e1038c61 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -424,6 +424,13 @@ export interface EmDashHandlers { db: Kysely; getPublicMediaUrl?: (storageKey: string) => string; + // Resize same-origin media at request time, reading bytes from the storage + // adapter and transforming via the configured image service (e.g. the + // Cloudflare IMAGES binding). Present only when `images` is configured. + // Used by the transform route; the Image components check its presence to + // decide whether to emit transform-route URLs. + transformImage?: import("../media/image-transform.js").TransformImageFn; + // Hook pipeline for plugin integrations hooks: import("../plugins/hooks.js").HookPipeline; diff --git a/packages/core/src/components/EmDashImage.astro b/packages/core/src/components/EmDashImage.astro index 5954f27302..d89e6a0ef9 100644 --- a/packages/core/src/components/EmDashImage.astro +++ b/packages/core/src/components/EmDashImage.astro @@ -27,6 +27,7 @@ import { toAbsoluteMediaUrl, RESPONSIVE_BREAKPOINTS, } from "../media/responsive.js"; +import { buildTransformedImage } from "../media/image-transform.js"; import { getPublicOrigin } from "../api/public-url.js"; interface Props extends Omit< @@ -103,20 +104,35 @@ if (img) { const providerId = img.provider ?? "local"; if (providerId === "local" || img.src) { - // Local provider or direct src URL. Route through Astro's image service - // (`astro:assets`) to generate a responsive srcset; on Cloudflare this is - // the Images binding, on Node it is sharp. Falls back to a plain - // when the service is unavailable or the source can't be optimized. + // Local provider or direct src URL. src = img.src || buildLocalImageUrl(img); - const optimized = await buildResponsiveImage(getImage, { - src: toAbsoluteMediaUrl(src, getPublicOrigin(Astro.url, Astro.locals.emdash?.config)), - width: finalWidth, - height: finalHeight, - }); - if (optimized) { - src = optimized.src; - srcset = optimized.srcset; - sizes = optimized.sizes; + + // Prefer EmDash's own transform route for same-origin media when an + // image transformer is configured (e.g. the Cloudflare IMAGES binding): + // it reads bytes straight from storage with no server-side fetch, so it + // works behind Cloudflare Access. Otherwise route through Astro's image + // service (`astro:assets`) — sharp on Node, or a CDN/publicUrl host — + // falling back to a plain when the source can't be optimized. + const transformed = buildTransformedImage( + Boolean(Astro.locals.emdash?.transformImage), + src, + { width: finalWidth, height: finalHeight }, + ); + if (transformed) { + src = transformed.src; + srcset = transformed.srcset; + sizes = transformed.sizes; + } else { + const optimized = await buildResponsiveImage(getImage, { + src: toAbsoluteMediaUrl(src, getPublicOrigin(Astro.url, Astro.locals.emdash?.config)), + width: finalWidth, + height: finalHeight, + }); + if (optimized) { + src = optimized.src; + srcset = optimized.srcset; + sizes = optimized.sizes; + } } } else { // External provider diff --git a/packages/core/src/components/Image.astro b/packages/core/src/components/Image.astro index 9f2e452f17..766430a1bb 100644 --- a/packages/core/src/components/Image.astro +++ b/packages/core/src/components/Image.astro @@ -14,6 +14,7 @@ import { toAbsoluteMediaUrl, RESPONSIVE_BREAKPOINTS, } from "../media/responsive.js"; +import { buildTransformedImage } from "../media/image-transform.js"; import { getPublicOrigin } from "../api/public-url.js"; export interface Props { @@ -136,17 +137,31 @@ if (!src) { url: asset.url, id: asset._ref, }); - // Generate a responsive srcset via Astro's image service for local/R2 media. - // Falls back to the plain URL when optimization isn't possible. - const optimized = await buildResponsiveImage(getImage, { - src: toAbsoluteMediaUrl(src, getPublicOrigin(Astro.url, Astro.locals.emdash?.config)), + // Prefer EmDash's own transform route for same-origin media when an image + // transformer is configured (e.g. the Cloudflare IMAGES binding): it reads + // bytes straight from storage with no server-side fetch, so it works behind + // Cloudflare Access. Otherwise generate a responsive srcset via Astro's + // image service for local/R2 media, falling back to the plain URL when + // optimization isn't possible. + const transformed = buildTransformedImage(Boolean(Astro.locals.emdash?.transformImage), src, { width: renderWidth, height: renderHeight, }); - if (optimized) { - src = optimized.src; - srcset = optimized.srcset; - sizes = optimized.sizes; + if (transformed) { + src = transformed.src; + srcset = transformed.srcset; + sizes = transformed.sizes; + } else { + const optimized = await buildResponsiveImage(getImage, { + src: toAbsoluteMediaUrl(src, getPublicOrigin(Astro.url, Astro.locals.emdash?.config)), + width: renderWidth, + height: renderHeight, + }); + if (optimized) { + src = optimized.src; + srcset = optimized.srcset; + sizes = optimized.sizes; + } } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 640e6599be..0f40d6efa6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -192,6 +192,17 @@ export type { } from "./storage/types.js"; export { EmDashStorageError } from "./storage/types.js"; +// Image transform service (binding-based, same-origin media) +export type { + ImageServiceDescriptor, + ImageTransformer, + ImageTransformOptions, + ImageTransformFormat, + TransformImageFn, + TransformedImage, + CreateImageTransformerFn, +} from "./media/image-transform.js"; + // Plugin system export { definePlugin, diff --git a/packages/core/src/media/image-transform.ts b/packages/core/src/media/image-transform.ts new file mode 100644 index 0000000000..8f7567faca --- /dev/null +++ b/packages/core/src/media/image-transform.ts @@ -0,0 +1,221 @@ +/** + * Binding-based image transforms for same-origin (R2 / local) media. + * + * The responsive-srcset path in `responsive.ts` hands an **absolute** media URL + * to Astro's image service (`astro:assets`), which makes the running server + * `fetch()` that URL to load the source bytes before transforming. On + * Cloudflare that absolute URL is the Worker's own origin, so the load is a + * self-referential subrequest — and it fails whenever the origin is gated + * (Cloudflare Access) or loopback fetches are disabled + * (`global_fetch_strictly_public`), surfacing as a 404 from `/_image`. + * + * This module powers an alternative that never leaves the Worker: EmDash serves + * transforms from its own route (`/_emdash/api/media/transform/{key}`), which + * reads the source bytes straight from the storage adapter (the R2 binding) and + * resizes them with a provided {@link ImageTransformer} (the Cloudflare `IMAGES` + * binding). The browser requests that route directly — with its own auth cookie + * when behind Access — so there is no server-side loopback fetch. + * + * The transformer itself is platform-specific and is injected at runtime via a + * serializable descriptor (see `images` in EmDashConfig); this module stays + * portable (no Cloudflare imports) so it can build URLs and run on Node too. + */ + +import { INTERNAL_MEDIA_PREFIX } from "./normalize.js"; +import { responsiveSizes, responsiveWidths, type ResponsiveImage } from "./responsive.js"; + +/** Route prefix that serves binding-based transforms. */ +export const TRANSFORM_MEDIA_PREFIX = "/_emdash/api/media/transform/"; + +/** Output formats the transform route accepts. */ +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 width. Caps the work a single request can ask the + * binding to do and keeps the generated `srcset` candidate list bounded. + */ +export const MAX_TRANSFORM_WIDTH = 4000; + +/** A format string accepted by {@link ImageTransformOptions.format}. */ +export type ImageTransformFormat = (typeof ALLOWED_TRANSFORM_FORMATS)[number]; + +/** Options for a single transform. */ +export interface ImageTransformOptions { + /** Target width in pixels. */ + width?: number; + /** Target height in pixels. Omit to preserve aspect ratio. */ + height?: number; + /** Output format. Defaults to {@link DEFAULT_TRANSFORM_FORMAT}. */ + format?: ImageTransformFormat; + /** Output quality (1-100). Adapter-defined default when omitted. */ + quality?: number; +} + +/** Result of a transform: a fresh body stream plus its resolved MIME type. */ +export interface TransformedImage { + body: ReadableStream; + contentType: string; +} + +/** + * Transforms image source bytes. Implemented by a platform adapter (e.g. the + * Cloudflare `IMAGES` binding). Receives the source as a stream — exactly what + * `Storage.download()` returns — so no intermediate buffering is required. + */ +export type TransformImageFn = ( + input: ReadableStream, + options: ImageTransformOptions, +) => Promise; + +/** Object form of {@link TransformImageFn}, returned by the runtime factory. */ +export interface ImageTransformer { + transform: TransformImageFn; +} + +/** + * Serializable descriptor for an image transformer, mirroring the storage + * descriptor pattern: a config-time function returns `{ entrypoint, config }`, + * and the runtime statically imports `createImageTransformer` from `entrypoint`. + */ +export interface ImageServiceDescriptor { + /** Module path exporting `createImageTransformer`. */ + entrypoint: string; + /** Serializable config passed to `createImageTransformer` at runtime. */ + config: unknown; +} + +/** The factory each image-transformer entrypoint must export. */ +export type CreateImageTransformerFn = (config: Record) => ImageTransformer; + +/** Storage keys safe to embed in a transform URL: a flat `{ulid}{ext}` shape. */ +const SAFE_TRANSFORM_KEY = /^[A-Za-z0-9._-]+$/; + +/** + * Whether a storage key is safe to serve through the transform route. Rejects + * anything with slashes, traversal, or query/fragment characters so the key + * can't reroute or traverse on the storage backend. + */ +export function isSafeTransformKey(key: string): boolean { + return SAFE_TRANSFORM_KEY.test(key); +} + +/** Build the transform-route URL for a single rendition. */ +export function buildTransformUrl(key: string, options: ImageTransformOptions): string { + const params = new URLSearchParams(); + if (options.width) params.set("w", String(options.width)); + if (options.height) params.set("h", String(options.height)); + params.set("f", options.format ?? DEFAULT_TRANSFORM_FORMAT); + if (options.quality) params.set("q", String(options.quality)); + return `${TRANSFORM_MEDIA_PREFIX}${encodeURIComponent(key)}?${params.toString()}`; +} + +/** + * Build a responsive `srcset` of transform-route URLs across the standard + * breakpoints (up to 2x the rendered width), preserving aspect ratio when a + * height is supplied. + */ +export function buildTransformSrcset(key: string, options: ImageTransformOptions): string { + const width = options.width; + if (!width) return ""; + const aspectRatio = width && options.height ? width / options.height : undefined; + return responsiveWidths(width) + .map((w) => { + const h = aspectRatio ? Math.round(w / aspectRatio) : undefined; + return `${buildTransformUrl(key, { ...options, width: w, height: h })} ${w}w`; + }) + .join(", "); +} + +/** + * Build a {@link ResponsiveImage} that points at the transform route, or + * `null` to fall back to the caller's existing path. + * + * Returns `null` unless all of these hold: + * - a transformer is available (`enabled`), + * - the rendered width is known (needed to size the `srcset`), + * - `src` is an internal same-origin media URL (`/_emdash/api/media/file/{key}`) + * with a safe key — external CDN/`publicUrl` media is a genuinely remote + * origin and is better served by the `astro:assets` path, which doesn't + * require a Worker self-fetch. + */ +export function buildTransformedImage( + enabled: boolean, + src: string, + options: { width?: number; height?: number; format?: ImageTransformFormat }, +): ResponsiveImage | null { + if (!enabled || !options.width) return null; + if (!src.startsWith(INTERNAL_MEDIA_PREFIX)) return null; + const key = src.slice(INTERNAL_MEDIA_PREFIX.length); + if (!key || !isSafeTransformKey(key)) return null; + const transformOptions: ImageTransformOptions = { + width: options.width, + height: options.height, + format: options.format ?? DEFAULT_TRANSFORM_FORMAT, + }; + return { + src: buildTransformUrl(key, transformOptions), + srcset: buildTransformSrcset(key, transformOptions) || undefined, + sizes: responsiveSizes(options.width), + }; +} + +/** 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 for the transform route. + * Width is required; bounds are clamped/rejected so a request can't ask the + * binding 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 or missing '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 } }; +} + +/** Type guard for {@link ImageTransformFormat}. */ +export function isTransformFormat(value: string): value is ImageTransformFormat { + return (ALLOWED_TRANSFORM_FORMATS as readonly string[]).includes(value); +} + +/** + * Parse a dimension query value. + * - `undefined`: param absent + * - `null`: present but invalid (non-integer, out of range) + * - `number`: valid, clamped to [1, MAX_TRANSFORM_WIDTH] + */ +function parseDimension(raw: string | null): number | undefined | null { + if (raw === null) return undefined; + const n = Number(raw); + if (!Number.isInteger(n) || n < 1 || n > MAX_TRANSFORM_WIDTH) return null; + return n; +} diff --git a/packages/core/src/media/index.ts b/packages/core/src/media/index.ts index 2879a072cb..38b4d98c9e 100644 --- a/packages/core/src/media/index.ts +++ b/packages/core/src/media/index.ts @@ -30,3 +30,26 @@ export { generatePlaceholder, type PlaceholderData } from "./placeholder.js"; // Built-in providers export { localMedia, type LocalMediaConfig } from "./local.js"; + +// Image transform service (binding-based, same-origin media) +export type { + ImageServiceDescriptor, + ImageTransformer, + ImageTransformOptions, + ImageTransformFormat, + TransformImageFn, + TransformedImage, + CreateImageTransformerFn, +} from "./image-transform.js"; +export { + ALLOWED_TRANSFORM_FORMATS, + DEFAULT_TRANSFORM_FORMAT, + MAX_TRANSFORM_WIDTH, + TRANSFORM_MEDIA_PREFIX, + buildTransformUrl, + buildTransformSrcset, + buildTransformedImage, + isSafeTransformKey, + isTransformFormat, + parseTransformParams, +} from "./image-transform.js"; diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index 9f71aaea01..cec9109b54 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -65,6 +65,13 @@ declare module "virtual:emdash/storage" { export const createStorage: ((config: Record) => Storage) | undefined; } +declare module "virtual:emdash/images" { + import type { CreateImageTransformerFn } from "./media/image-transform.js"; + + // Can be undefined if no image service configured, or the actual factory + export const createImageTransformer: CreateImageTransformerFn | undefined; +} + declare module "virtual:emdash/auth" { import type { AuthResult } from "./auth/types.js"; diff --git a/packages/core/tests/unit/astro/middleware-prerender.test.ts b/packages/core/tests/unit/astro/middleware-prerender.test.ts index d58ed8cd44..5086525a96 100644 --- a/packages/core/tests/unit/astro/middleware-prerender.test.ts +++ b/packages/core/tests/unit/astro/middleware-prerender.test.ts @@ -117,6 +117,7 @@ vi.mock( ); vi.mock("virtual:emdash/sandboxed-plugins", () => ({ sandboxedPlugins: [] }), { virtual: true }); vi.mock("virtual:emdash/storage", () => ({ createStorage: null }), { virtual: true }); +vi.mock("virtual:emdash/images", () => ({ createImageTransformer: undefined }), { virtual: true }); vi.mock("virtual:emdash/wait-until", () => ({ waitUntil: undefined }), { virtual: true }); vi.mock("virtual:emdash/scheduler", () => ({ createScheduler: null }), { virtual: true }); diff --git a/packages/core/tests/unit/astro/routes.test.ts b/packages/core/tests/unit/astro/routes.test.ts index da4ed68504..7604b498a2 100644 --- a/packages/core/tests/unit/astro/routes.test.ts +++ b/packages/core/tests/unit/astro/routes.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { injectCoreRoutes } from "../../../src/astro/integration/routes.js"; import { GET as getMediaFile } from "../../../src/astro/routes/api/media/file/[...key].js"; +import { GET as getMediaTransform } from "../../../src/astro/routes/api/media/transform/[...key].js"; function mockMediaContext(key: string | undefined) { const download = vi.fn().mockResolvedValue({ @@ -62,3 +63,105 @@ describe("media file catch-all route", () => { expect(download).not.toHaveBeenCalled(); }); }); + +function mockTransformContext( + key: string | undefined, + query: string, + opts: { + contentType?: string; + transformImage?: ReturnType; + } = {}, +) { + const download = vi.fn().mockResolvedValue({ + body: new Uint8Array([1, 2, 3]), + contentType: opts.contentType ?? "image/jpeg", + size: 3, + }); + const emdash: Record = { storage: { download } }; + if (opts.transformImage) emdash.transformImage = opts.transformImage; + + return { + context: { + params: { key }, + url: new URL(`http://localhost/_emdash/api/media/transform/${key ?? ""}?${query}`), + locals: { emdash }, + } as unknown as Parameters[0], + download, + }; +} + +describe("media transform route injection", () => { + it("injects a catch-all transform route", () => { + const routes: Array<{ pattern: string; entrypoint: string }> = []; + injectCoreRoutes((route) => { + routes.push({ ...route, entrypoint: route.entrypoint.replaceAll("\\", "/") }); + }); + + expect(routes).toContainEqual( + expect.objectContaining({ + pattern: "/_emdash/api/media/transform/[...key]", + entrypoint: expect.stringContaining("api/media/transform/_...key_"), + }), + ); + }); +}); + +describe("media transform route handler", () => { + it("transforms the source via the configured transformer", async () => { + const transformImage = vi.fn().mockResolvedValue({ + body: new Uint8Array([9, 9]), + contentType: "image/webp", + }); + const { context, download } = mockTransformContext("01ABC.jpg", "w=480&f=webp", { + transformImage, + }); + + const response = await getMediaTransform(context); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/webp"); + expect(download).toHaveBeenCalledWith("01ABC.jpg"); + expect(transformImage).toHaveBeenCalledWith(expect.anything(), { + width: 480, + height: undefined, + format: "webp", + quality: undefined, + }); + }); + + it("streams the original through when no transformer is configured", async () => { + const { context, download } = mockTransformContext("01ABC.jpg", "w=480"); + + const response = await getMediaTransform(context); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/jpeg"); + expect(download).toHaveBeenCalledWith("01ABC.jpg"); + }); + + it("rejects an unsafe (slash-containing) key with 404", async () => { + const { context, download } = mockTransformContext("nested/path.jpg", "w=480"); + + const response = await getMediaTransform(context); + expect(response.status).toBe(404); + expect(download).not.toHaveBeenCalled(); + }); + + it("rejects invalid params with 400", async () => { + const { context, download } = mockTransformContext("01ABC.jpg", "h=270"); + + const response = await getMediaTransform(context); + expect(response.status).toBe(400); + expect(download).not.toHaveBeenCalled(); + }); + + it("rejects a non-image source with 400", async () => { + const transformImage = vi.fn(); + const { context } = mockTransformContext("01ABC.pdf", "w=480", { + contentType: "application/pdf", + transformImage, + }); + + const response = await getMediaTransform(context); + expect(response.status).toBe(400); + expect(transformImage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/tests/unit/media/image-transform.test.ts b/packages/core/tests/unit/media/image-transform.test.ts new file mode 100644 index 0000000000..381b785a20 --- /dev/null +++ b/packages/core/tests/unit/media/image-transform.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; + +import { + ALLOWED_TRANSFORM_FORMATS, + DEFAULT_TRANSFORM_FORMAT, + MAX_TRANSFORM_WIDTH, + TRANSFORM_MEDIA_PREFIX, + buildTransformUrl, + buildTransformSrcset, + buildTransformedImage, + isSafeTransformKey, + isTransformFormat, + parseTransformParams, +} from "../../../src/media/image-transform.js"; +import { responsiveWidths } from "../../../src/media/responsive.js"; + +const KEY = "01ABCDEF.jpg"; +const INTERNAL = `/_emdash/api/media/file/${KEY}`; + +describe("buildTransformUrl", () => { + it("builds a transform-route URL with width and default format", () => { + expect(buildTransformUrl(KEY, { width: 480 })).toBe( + `${TRANSFORM_MEDIA_PREFIX}${KEY}?w=480&f=${DEFAULT_TRANSFORM_FORMAT}`, + ); + }); + + it("includes height, format, and quality when provided", () => { + expect(buildTransformUrl(KEY, { width: 480, height: 270, format: "avif", quality: 80 })).toBe( + `${TRANSFORM_MEDIA_PREFIX}${KEY}?w=480&h=270&f=avif&q=80`, + ); + }); + + it("url-encodes the key", () => { + expect(buildTransformUrl("a b.jpg", { width: 100 })).toContain("a%20b.jpg"); + }); +}); + +describe("buildTransformSrcset", () => { + it("emits a candidate per responsive width, preserving aspect ratio", () => { + const srcset = buildTransformSrcset(KEY, { width: 400, height: 200 }); + const entries = srcset.split(", "); + expect(entries).toHaveLength(responsiveWidths(400).length); + // 2:1 aspect ratio is preserved: width 640 -> height 320 + expect(srcset).toContain(`${TRANSFORM_MEDIA_PREFIX}${KEY}?w=640&h=320&f=webp 640w`); + }); + + it("returns an empty string without a width", () => { + expect(buildTransformSrcset(KEY, {})).toBe(""); + }); +}); + +describe("buildTransformedImage", () => { + it("returns a transform-route rendition for internal media when enabled", () => { + const result = buildTransformedImage(true, INTERNAL, { width: 480, height: 270 }); + expect(result).not.toBeNull(); + expect(result?.src).toBe(`${TRANSFORM_MEDIA_PREFIX}${KEY}?w=480&h=270&f=webp`); + expect(result?.srcset).toContain(" 480w"); + expect(result?.sizes).toBe("(min-width: 480px) 480px, 100vw"); + }); + + it("returns null when no transformer is available", () => { + expect(buildTransformedImage(false, INTERNAL, { width: 480, height: 270 })).toBeNull(); + }); + + it("returns null when width is unknown", () => { + expect(buildTransformedImage(true, INTERNAL, {})).toBeNull(); + }); + + it("returns null for external/CDN URLs (handled by the astro:assets path)", () => { + expect( + buildTransformedImage(true, "https://cdn.example.com/01ABCDEF.jpg", { + width: 480, + height: 270, + }), + ).toBeNull(); + }); + + it("returns null for an internal URL whose key is unsafe", () => { + expect( + buildTransformedImage(true, "/_emdash/api/media/file/../secret", { width: 480 }), + ).toBeNull(); + }); +}); + +describe("isSafeTransformKey", () => { + it("accepts flat ulid-with-extension keys", () => { + expect(isSafeTransformKey("01HXYZ.webp")).toBe(true); + expect(isSafeTransformKey("a-b_c.JPG")).toBe(true); + }); + + it("rejects slashes, traversal, and query characters", () => { + expect(isSafeTransformKey("a/b.jpg")).toBe(false); + expect(isSafeTransformKey("../secret")).toBe(false); + expect(isSafeTransformKey("a.jpg?x=1")).toBe(false); + expect(isSafeTransformKey("")).toBe(false); + }); +}); + +describe("isTransformFormat", () => { + it("accepts allowed formats and rejects others", () => { + for (const f of ALLOWED_TRANSFORM_FORMATS) expect(isTransformFormat(f)).toBe(true); + expect(isTransformFormat("gif")).toBe(false); + expect(isTransformFormat("svg")).toBe(false); + }); +}); + +describe("parseTransformParams", () => { + const parse = (qs: string) => parseTransformParams(new URLSearchParams(qs)); + + it("parses width, height, format, and quality", () => { + const result = parse("w=480&h=270&f=avif&q=75"); + expect(result).toEqual({ + ok: true, + options: { width: 480, height: 270, format: "avif", quality: 75 }, + }); + }); + + it("defaults the format when omitted", () => { + const result = parse("w=480"); + expect(result.ok && result.options.format).toBe(DEFAULT_TRANSFORM_FORMAT); + }); + + it("requires width", () => { + expect(parse("h=270").ok).toBe(false); + }); + + it("rejects a non-integer or out-of-range width", () => { + expect(parse("w=abc").ok).toBe(false); + expect(parse("w=0").ok).toBe(false); + expect(parse(`w=${MAX_TRANSFORM_WIDTH + 1}`).ok).toBe(false); + }); + + it("rejects an unsupported format", () => { + expect(parse("w=480&f=gif").ok).toBe(false); + }); + + it("rejects an out-of-range quality", () => { + expect(parse("w=480&q=0").ok).toBe(false); + expect(parse("w=480&q=101").ok).toBe(false); + }); +});