354 lines
10 KiB
TypeScript
354 lines
10 KiB
TypeScript
/**
|
|
* 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;
|
|
};
|
|
}
|