first commit
This commit is contained in:
353
packages/cloudflare/src/media/images-runtime.ts
Normal file
353
packages/cloudflare/src/media/images-runtime.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Cloudflare Images Runtime Module
|
||||
*
|
||||
* This module is imported at runtime by the media provider system.
|
||||
* It contains the actual provider implementation that interacts with the Cloudflare API.
|
||||
*/
|
||||
|
||||
import { env } from "cloudflare:workers";
|
||||
import type {
|
||||
MediaProvider,
|
||||
MediaListOptions,
|
||||
MediaValue,
|
||||
EmbedOptions,
|
||||
EmbedResult,
|
||||
CreateMediaProviderFn,
|
||||
} from "emdash/media";
|
||||
|
||||
import type { CloudflareImagesConfig } from "./images.js";
|
||||
|
||||
/** Safely extract a number from an unknown value */
|
||||
function toNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a config value, checking env var if direct value not provided
|
||||
*/
|
||||
function resolveEnvValue(
|
||||
directValue: string | undefined,
|
||||
envVarName: string | undefined,
|
||||
defaultEnvVar: string,
|
||||
serviceName: string,
|
||||
): string {
|
||||
if (directValue) return directValue;
|
||||
const envVar = envVarName || defaultEnvVar;
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding accessed from untyped env object
|
||||
const value = (env as Record<string, string | undefined>)[envVar];
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
`${serviceName}: Missing ${envVar}. Set it as an environment variable or provide it directly in config.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime implementation for Cloudflare Images provider
|
||||
*/
|
||||
export const createMediaProvider: CreateMediaProviderFn<CloudflareImagesConfig> = (config) => {
|
||||
const { deliveryDomain, defaultVariant = "public" } = config;
|
||||
|
||||
// Lazy getters - resolve env vars at request time, not module init time
|
||||
const getAccountId = () =>
|
||||
resolveEnvValue(config.accountId, config.accountIdEnvVar, "CF_ACCOUNT_ID", "Cloudflare Images");
|
||||
const getAccountHash = () =>
|
||||
resolveEnvValue(
|
||||
config.accountHash,
|
||||
config.accountHashEnvVar,
|
||||
"CF_IMAGES_ACCOUNT_HASH",
|
||||
"Cloudflare Images",
|
||||
);
|
||||
const getApiToken = () =>
|
||||
resolveEnvValue(config.apiToken, config.apiTokenEnvVar, "CF_IMAGES_TOKEN", "Cloudflare Images");
|
||||
const getApiBase = () =>
|
||||
`https://api.cloudflare.com/client/v4/accounts/${getAccountId()}/images/v1`;
|
||||
const getHeaders = () => ({ Authorization: `Bearer ${getApiToken()}` });
|
||||
const getDeliveryBase = () =>
|
||||
deliveryDomain ? `https://${deliveryDomain}` : "https://imagedelivery.net";
|
||||
|
||||
// Build a delivery URL with flexible variant transforms
|
||||
const buildUrl = (imageId: string, transforms?: { w?: number; h?: number; fit?: string }) => {
|
||||
const base = `${getDeliveryBase()}/${getAccountHash()}/${imageId}`;
|
||||
if (!transforms || Object.keys(transforms).length === 0) {
|
||||
return `${base}/${defaultVariant}`;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (transforms.w) parts.push(`w=${transforms.w}`);
|
||||
if (transforms.h) parts.push(`h=${transforms.h}`);
|
||||
if (transforms.fit) parts.push(`fit=${transforms.fit}`);
|
||||
return `${base}/${parts.join(",")}`;
|
||||
};
|
||||
|
||||
// Fetch image dimensions via the format=json delivery endpoint
|
||||
// This is a public endpoint that doesn't require authentication
|
||||
const fetchDimensions = async (
|
||||
imageId: string,
|
||||
): Promise<{ width: number; height: number } | null> => {
|
||||
const url = `${getDeliveryBase()}/${getAccountHash()}/${imageId}/format=json`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const data: ImageJsonResponse = await response.json();
|
||||
return { width: data.width, height: data.height };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const provider: MediaProvider = {
|
||||
async list(options: MediaListOptions) {
|
||||
const apiBase = getApiBase();
|
||||
const headers = getHeaders();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (options.cursor) {
|
||||
params.set("continuation_token", options.cursor);
|
||||
}
|
||||
if (options.limit) {
|
||||
params.set("per_page", String(options.limit));
|
||||
}
|
||||
|
||||
const url = `${apiBase}?${params}`;
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Cloudflare Images API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: CloudflareImagesListResponse = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(
|
||||
`Cloudflare Images API error: ${data.errors?.[0]?.message || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter out images that require signed URLs (not supported yet)
|
||||
const publicImages = data.result.images.filter((img) => !img.requireSignedURLs);
|
||||
|
||||
// Fetch dimensions for all images in parallel
|
||||
const dimensionsMap = new Map<string, { width: number; height: number }>();
|
||||
const dimensionResults = await Promise.all(
|
||||
publicImages.map(async (img) => {
|
||||
const dims = await fetchDimensions(img.id);
|
||||
return { id: img.id, dims };
|
||||
}),
|
||||
);
|
||||
for (const { id, dims } of dimensionResults) {
|
||||
if (dims) dimensionsMap.set(id, dims);
|
||||
}
|
||||
|
||||
return {
|
||||
items: publicImages.map((img) => {
|
||||
const dims = dimensionsMap.get(img.id);
|
||||
return {
|
||||
id: img.id,
|
||||
filename: img.filename || img.id,
|
||||
mimeType: "image/jpeg", // CF Images doesn't expose original mime type
|
||||
width: dims?.width ?? toNumber(img.meta?.width),
|
||||
height: dims?.height ?? toNumber(img.meta?.height),
|
||||
// Use 400px wide preview for grid thumbnails (good for 2x retina on ~200px grid)
|
||||
previewUrl: buildUrl(img.id, { w: 400, fit: "scale-down" }),
|
||||
meta: {
|
||||
variants: img.variants,
|
||||
uploaded: img.uploaded,
|
||||
},
|
||||
};
|
||||
}),
|
||||
nextCursor: data.result.continuation_token || undefined,
|
||||
};
|
||||
},
|
||||
|
||||
async get(id: string) {
|
||||
const apiBase = getApiBase();
|
||||
const headers = getHeaders();
|
||||
|
||||
const url = `${apiBase}/${id}`;
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`Cloudflare Images API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: CloudflareImageResponse = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const img = data.result;
|
||||
|
||||
// Don't return images that require signed URLs (not supported yet)
|
||||
if (img.requireSignedURLs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch dimensions via format=json endpoint
|
||||
const dims = await fetchDimensions(img.id);
|
||||
|
||||
return {
|
||||
id: img.id,
|
||||
filename: img.filename || img.id,
|
||||
mimeType: "image/jpeg",
|
||||
width: dims?.width ?? toNumber(img.meta?.width),
|
||||
height: dims?.height ?? toNumber(img.meta?.height),
|
||||
// Use larger preview for detail view
|
||||
previewUrl: buildUrl(img.id, { w: 800, fit: "scale-down" }),
|
||||
meta: {
|
||||
variants: img.variants,
|
||||
uploaded: img.uploaded,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async upload(input) {
|
||||
const apiBase = getApiBase();
|
||||
const apiToken = getApiToken();
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", input.file, input.filename);
|
||||
|
||||
// Ensure uploaded images are public (don't require signed URLs)
|
||||
formData.append("requireSignedURLs", "false");
|
||||
|
||||
// Add metadata if provided
|
||||
const metadata: Record<string, string> = {};
|
||||
if (input.alt) {
|
||||
metadata.alt = input.alt;
|
||||
}
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
formData.append("metadata", JSON.stringify(metadata));
|
||||
}
|
||||
|
||||
const response = await fetch(apiBase, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
// Don't set Content-Type - let browser set it with boundary
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Cloudflare Images upload failed: ${error}`);
|
||||
}
|
||||
|
||||
const data: CloudflareImageResponse = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(
|
||||
`Cloudflare Images upload failed: ${data.errors?.[0]?.message || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const img = data.result;
|
||||
return {
|
||||
id: img.id,
|
||||
filename: img.filename || input.filename,
|
||||
mimeType: "image/jpeg",
|
||||
width: toNumber(img.meta?.width),
|
||||
height: toNumber(img.meta?.height),
|
||||
previewUrl: buildUrl(img.id, { w: 400, fit: "scale-down" }),
|
||||
meta: {
|
||||
variants: img.variants,
|
||||
uploaded: img.uploaded,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const apiBase = getApiBase();
|
||||
const headers = getHeaders();
|
||||
|
||||
const response = await fetch(`${apiBase}/${id}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`Cloudflare Images delete failed: ${response.status}`);
|
||||
}
|
||||
},
|
||||
|
||||
getEmbed(value: MediaValue, options?: EmbedOptions): EmbedResult {
|
||||
const accountHash = getAccountHash();
|
||||
const deliveryBase = getDeliveryBase();
|
||||
const baseUrl = `${deliveryBase}/${accountHash}/${value.id}`;
|
||||
|
||||
// Helper to build URL with transforms
|
||||
const buildSrc = (opts: { width?: number; height?: number; format?: string }) => {
|
||||
const t: string[] = [];
|
||||
if (opts.width) t.push(`w=${opts.width}`);
|
||||
if (opts.height) t.push(`h=${opts.height}`);
|
||||
if (opts.format) t.push(`f=${opts.format}`);
|
||||
t.push("fit=scale-down");
|
||||
return `${baseUrl}/${t.join(",")}`;
|
||||
};
|
||||
|
||||
// Build src URL - always include transforms (CF Images requires a variant)
|
||||
const width = options?.width ?? value.width ?? 1200;
|
||||
const height = options?.height ?? value.height;
|
||||
const src = buildSrc({ width, height, format: options?.format });
|
||||
|
||||
return {
|
||||
type: "image",
|
||||
src,
|
||||
width: options?.width ?? value.width,
|
||||
height: options?.height ?? value.height,
|
||||
alt: value.alt,
|
||||
// Provide getSrc for dynamic resizing (e.g., responsive images)
|
||||
getSrc: buildSrc,
|
||||
};
|
||||
},
|
||||
|
||||
getThumbnailUrl(id: string, _mimeType?: string, options?: { width?: number; height?: number }) {
|
||||
// For images, return a sized delivery URL
|
||||
const width = options?.width || 400;
|
||||
const height = options?.height;
|
||||
return buildUrl(id, { w: width, h: height, fit: "scale-down" });
|
||||
},
|
||||
};
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
// Cloudflare API response types
|
||||
interface CloudflareImagesListResponse {
|
||||
success: boolean;
|
||||
errors?: Array<{ message: string }>;
|
||||
result: {
|
||||
images: CloudflareImage[];
|
||||
continuation_token?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CloudflareImageResponse {
|
||||
success: boolean;
|
||||
errors?: Array<{ message: string }>;
|
||||
result: CloudflareImage;
|
||||
}
|
||||
|
||||
interface CloudflareImage {
|
||||
id: string;
|
||||
filename?: string;
|
||||
uploaded: string;
|
||||
requireSignedURLs: boolean;
|
||||
variants: string[];
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Response from format=json delivery endpoint
|
||||
interface ImageJsonResponse {
|
||||
width: number;
|
||||
height: number;
|
||||
original: {
|
||||
file_size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user