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
21 changes: 21 additions & 0 deletions .changeset/image-transform-binding.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
49 changes: 48 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,
ImageServiceDescriptor,
StorageDescriptor,
} from "emdash";

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

Expand Down Expand Up @@ -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
*
Expand Down
68 changes: 68 additions & 0 deletions packages/cloudflare/src/media/transform-runtime.ts
Original file line number Diff line number Diff line change
@@ -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<ImageTransformFormat, ImageOutputOptions["format"]> = {
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<string, unknown>)[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<TransformedImage> {
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,
};
},
};
};
2 changes: 2 additions & 0 deletions packages/cloudflare/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
const serializableConfig: Record<string, unknown> = {
database: resolvedConfig.database,
storage: resolvedConfig.storage,
images: resolvedConfig.images,
auth: resolvedConfig.auth,
authProviders: resolvedConfig.authProviders,
marketplace: resolvedConfig.marketplace,
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/astro/integration/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
*
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/astro/integration/virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/astro/integration/vite-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -50,6 +52,7 @@ import {
generateConfigModule,
generateDialectModule,
generateStorageModule,
generateImagesModule,
generateAuthModule,
generateAuthProvidersModule,
generatePluginsModule,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The _transformImage variable is a module-scope singleton cache. AGENTS.md explicitly requires these to live on globalThis because Vite can duplicate modules across SSR chunks, turning a plain let into multiple independent variables:

Module-scope singletons must live on globalThis. Vite duplicates modules across SSR chunks; a plain let cache = null becomes two variables. Use a Symbol.for key on globalThis.

Move the cache to globalThis so it is stable across chunk boundaries. For example:

Suggested change
// a missing binding doesn't re-throw and re-log on every request.
const kTransformImage = Symbol.for("emdash.transformImage");
function getTransformImage(config: EmDashConfig): TransformImageFn | undefined {
const cached = (globalThis as Record<symbol, TransformImageFn | null | undefined>)[kTransformImage];
if (cached !== undefined) return cached ?? undefined;

...and store the result back onto globalThis at each exit point instead of the module-scoped _transformImage.

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<string, unknown>);
_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
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading