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;
|
||||
};
|
||||
}
|
||||
114
packages/cloudflare/src/media/images.ts
Normal file
114
packages/cloudflare/src/media/images.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Cloudflare Images Media Provider
|
||||
*
|
||||
* Provides integration with Cloudflare Images for image hosting and transformation.
|
||||
*
|
||||
* Features:
|
||||
* - Browse uploaded images
|
||||
* - Upload new images
|
||||
* - Delete images
|
||||
* - URL-based image transformations (resize, format conversion, etc.)
|
||||
*
|
||||
* @see https://developers.cloudflare.com/images/
|
||||
*/
|
||||
|
||||
import type { MediaProviderDescriptor } from "emdash/media";
|
||||
|
||||
/**
|
||||
* Cloudflare Images configuration
|
||||
*/
|
||||
export interface CloudflareImagesConfig {
|
||||
/**
|
||||
* Cloudflare Account ID (for API calls)
|
||||
* If not provided, reads from accountIdEnvVar at runtime
|
||||
*/
|
||||
accountId?: string;
|
||||
|
||||
/**
|
||||
* Environment variable name containing the Account ID
|
||||
* @default "CF_ACCOUNT_ID"
|
||||
*/
|
||||
accountIdEnvVar?: string;
|
||||
|
||||
/**
|
||||
* Cloudflare Images Account Hash (for delivery URLs)
|
||||
* This is different from the Account ID - find it in the Cloudflare dashboard
|
||||
* under Images > Overview > "Account Hash"
|
||||
* If not provided, reads from accountHashEnvVar at runtime
|
||||
*/
|
||||
accountHash?: string;
|
||||
|
||||
/**
|
||||
* Environment variable name containing the Account Hash
|
||||
* @default "CF_IMAGES_ACCOUNT_HASH"
|
||||
*/
|
||||
accountHashEnvVar?: string;
|
||||
|
||||
/**
|
||||
* API Token with Images permissions
|
||||
* If not provided, reads from apiTokenEnvVar at runtime
|
||||
* Should have "Cloudflare Images: Read" and "Cloudflare Images: Edit" permissions
|
||||
*/
|
||||
apiToken?: string;
|
||||
|
||||
/**
|
||||
* Environment variable name containing the API token
|
||||
* @default "CF_IMAGES_TOKEN"
|
||||
*/
|
||||
apiTokenEnvVar?: string;
|
||||
|
||||
/**
|
||||
* Custom delivery domain (optional)
|
||||
* If not specified, uses imagedelivery.net
|
||||
* @example "images.example.com"
|
||||
*/
|
||||
deliveryDomain?: string;
|
||||
|
||||
/**
|
||||
* Default variant to use for display
|
||||
* @default "public"
|
||||
*/
|
||||
defaultVariant?: string;
|
||||
}
|
||||
|
||||
// Cloudflare Images icon (inline SVG as data URL)
|
||||
const IMAGES_ICON = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64"><path fill="#F63" d="M56 11.92H8l-2 2v39.87l2 2h48l2-2V13.92l-2-2Zm-2 4v18.69l-8-6.55-2.62.08-5.08 4.68-5.43-4-2.47.08-14 11.7-6.4-4.4V15.92h44ZM10 51.79V41.08l5.3 3.7 2.42-.11L31.75 33l5.5 4 2.54-.14 5-4.63L54 39.77v12l-44 .02Z"/><path fill="#F63" d="M19.08 32.16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"/></svg>')}`;
|
||||
|
||||
/**
|
||||
* Cloudflare Images media provider
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { cloudflareImages } from "@emdashcms/cloudflare";
|
||||
*
|
||||
* emdash({
|
||||
* mediaProviders: [
|
||||
* // Uses CF_ACCOUNT_ID and CF_IMAGES_TOKEN env vars by default
|
||||
* cloudflareImages({}),
|
||||
*
|
||||
* // Or with custom env var names
|
||||
* cloudflareImages({
|
||||
* accountIdEnvVar: "MY_CF_ACCOUNT",
|
||||
* apiTokenEnvVar: "MY_CF_IMAGES_KEY",
|
||||
* }),
|
||||
* ],
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function cloudflareImages(
|
||||
config: CloudflareImagesConfig,
|
||||
): MediaProviderDescriptor<CloudflareImagesConfig> {
|
||||
return {
|
||||
id: "cloudflare-images",
|
||||
name: "Cloudflare Images",
|
||||
icon: IMAGES_ICON,
|
||||
entrypoint: "@emdashcms/cloudflare/media/images-runtime",
|
||||
capabilities: {
|
||||
browse: true,
|
||||
search: false, // Images API doesn't support search
|
||||
upload: true,
|
||||
delete: true,
|
||||
},
|
||||
config,
|
||||
};
|
||||
}
|
||||
392
packages/cloudflare/src/media/stream-runtime.ts
Normal file
392
packages/cloudflare/src/media/stream-runtime.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Cloudflare Stream 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 { CloudflareStreamConfig } from "./stream.js";
|
||||
|
||||
/** Safely extract a string from an unknown value */
|
||||
function toString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** Type guard: check if value is a record-like object */
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Stream provider
|
||||
*/
|
||||
export const createMediaProvider: CreateMediaProviderFn<CloudflareStreamConfig> = (config) => {
|
||||
const { customerSubdomain, controls = true, autoplay = false, loop = false, muted } = config;
|
||||
|
||||
// Resolve credentials from config or env vars
|
||||
const accountId = resolveEnvValue(
|
||||
config.accountId,
|
||||
config.accountIdEnvVar,
|
||||
"CF_ACCOUNT_ID",
|
||||
"Cloudflare Stream",
|
||||
);
|
||||
const apiToken = resolveEnvValue(
|
||||
config.apiToken,
|
||||
config.apiTokenEnvVar,
|
||||
"CF_STREAM_TOKEN",
|
||||
"Cloudflare Stream",
|
||||
);
|
||||
|
||||
const apiBase = `https://api.cloudflare.com/client/v4/accounts/${accountId}/stream`;
|
||||
const headers = { Authorization: `Bearer ${apiToken}` };
|
||||
|
||||
// Muted defaults to true if autoplay is enabled (browser requirement)
|
||||
const isMuted = muted ?? autoplay;
|
||||
|
||||
const provider: MediaProvider = {
|
||||
async list(options: MediaListOptions) {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Stream uses "after" for cursor-based pagination
|
||||
if (options.cursor) {
|
||||
params.set("after", options.cursor);
|
||||
}
|
||||
|
||||
// Stream uses "asc" boolean, default is newest first
|
||||
params.set("asc", "false");
|
||||
|
||||
// Search by name if query provided
|
||||
if (options.query) {
|
||||
params.set("search", options.query);
|
||||
}
|
||||
|
||||
const url = `${apiBase}?${params}`;
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Cloudflare Stream API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: CloudflareStreamListResponse = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(
|
||||
`Cloudflare Stream API error: ${data.errors?.[0]?.message || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the last video's UID for cursor-based pagination
|
||||
const lastVideo = data.result.at(-1);
|
||||
const nextCursor = lastVideo?.uid;
|
||||
|
||||
return {
|
||||
items: data.result.map((video) => ({
|
||||
id: video.uid,
|
||||
filename: toString(video.meta?.name) || video.uid,
|
||||
mimeType: "video/mp4",
|
||||
width: video.input?.width,
|
||||
height: video.input?.height,
|
||||
previewUrl: video.thumbnail,
|
||||
meta: {
|
||||
duration: video.duration,
|
||||
playback: video.playback,
|
||||
status: video.status,
|
||||
created: video.created,
|
||||
modified: video.modified,
|
||||
size: video.size,
|
||||
},
|
||||
})),
|
||||
nextCursor: data.result.length > 0 ? nextCursor : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
async get(id: string) {
|
||||
const url = `${apiBase}/${id}`;
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) return null;
|
||||
throw new Error(`Cloudflare Stream API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: CloudflareStreamResponse = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const video = data.result;
|
||||
return {
|
||||
id: video.uid,
|
||||
filename: toString(video.meta?.name) || video.uid,
|
||||
mimeType: "video/mp4",
|
||||
width: video.input?.width,
|
||||
height: video.input?.height,
|
||||
previewUrl: video.thumbnail,
|
||||
meta: {
|
||||
duration: video.duration,
|
||||
playback: video.playback,
|
||||
status: video.status,
|
||||
created: video.created,
|
||||
modified: video.modified,
|
||||
size: video.size,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async upload(input) {
|
||||
// Stream supports tus protocol for resumable uploads
|
||||
// For simplicity, we'll use direct creator upload which creates an upload URL
|
||||
// For large files, this would need to be enhanced with tus
|
||||
|
||||
// First, create a direct upload URL
|
||||
const createResponse = await fetch(`${apiBase}/direct_upload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
maxDurationSeconds: 3600, // 1 hour max
|
||||
meta: {
|
||||
name: input.filename,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const error = await createResponse.text();
|
||||
throw new Error(`Failed to create upload URL: ${error}`);
|
||||
}
|
||||
|
||||
const createData: CloudflareStreamDirectUploadResponse = await createResponse.json();
|
||||
|
||||
if (!createData.success) {
|
||||
throw new Error(
|
||||
`Failed to create upload URL: ${createData.errors?.[0]?.message || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Upload the file to the provided URL
|
||||
const uploadUrl = createData.result.uploadURL;
|
||||
const formData = new FormData();
|
||||
formData.append("file", input.file, input.filename);
|
||||
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
const error = await uploadResponse.text();
|
||||
throw new Error(`Upload failed: ${error}`);
|
||||
}
|
||||
|
||||
// The upload response contains the video details
|
||||
// Wait a moment for the video to be processed
|
||||
const videoId = createData.result.uid;
|
||||
|
||||
// Poll for the video to be ready (simple implementation)
|
||||
let video: CloudflareStreamVideo | null = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const checkResponse = await fetch(`${apiBase}/${videoId}`, { headers });
|
||||
if (checkResponse.ok) {
|
||||
const checkData: CloudflareStreamResponse = await checkResponse.json();
|
||||
if (checkData.success && checkData.result.status?.state !== "queued") {
|
||||
video = checkData.result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!video) {
|
||||
// Return with pending status - thumbnail might not be ready yet
|
||||
return {
|
||||
id: videoId,
|
||||
filename: input.filename,
|
||||
mimeType: "video/mp4",
|
||||
previewUrl: undefined,
|
||||
meta: {
|
||||
status: { state: "processing" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: video.uid,
|
||||
filename: toString(video.meta?.name) || input.filename,
|
||||
mimeType: "video/mp4",
|
||||
width: video.input?.width,
|
||||
height: video.input?.height,
|
||||
previewUrl: video.thumbnail,
|
||||
meta: {
|
||||
duration: video.duration,
|
||||
playback: video.playback,
|
||||
status: video.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const response = await fetch(`${apiBase}/${id}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`Cloudflare Stream delete failed: ${response.status}`);
|
||||
}
|
||||
},
|
||||
|
||||
getEmbed(value: MediaValue, options?: EmbedOptions): EmbedResult {
|
||||
const rawPlayback = value.meta?.playback;
|
||||
const playback = isRecord(rawPlayback) ? rawPlayback : undefined;
|
||||
|
||||
const hlsSrc = toString(playback?.hls);
|
||||
const dashSrc = toString(playback?.dash);
|
||||
|
||||
// Build the Stream player iframe URL or use HLS/DASH directly
|
||||
// For video embeds, we can use the HLS stream URL
|
||||
if (hlsSrc) {
|
||||
return {
|
||||
type: "video",
|
||||
sources: [
|
||||
{ src: hlsSrc, type: "application/x-mpegURL" },
|
||||
...(dashSrc ? [{ src: dashSrc, type: "application/dash+xml" }] : []),
|
||||
],
|
||||
poster: toString(value.meta?.thumbnail),
|
||||
width: options?.width ?? value.width,
|
||||
height: options?.height ?? value.height,
|
||||
controls,
|
||||
autoplay,
|
||||
loop,
|
||||
muted: isMuted,
|
||||
playsinline: true,
|
||||
preload: "metadata",
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: use the Stream embed player URL
|
||||
const baseUrl = customerSubdomain
|
||||
? `https://${customerSubdomain}`
|
||||
: `https://customer-${accountId.slice(0, 8)}.cloudflarestream.com`;
|
||||
|
||||
return {
|
||||
type: "video",
|
||||
src: `${baseUrl}/${value.id}/manifest/video.m3u8`,
|
||||
poster: `${baseUrl}/${value.id}/thumbnails/thumbnail.jpg`,
|
||||
width: options?.width ?? value.width,
|
||||
height: options?.height ?? value.height,
|
||||
controls,
|
||||
autoplay,
|
||||
loop,
|
||||
muted: isMuted,
|
||||
playsinline: true,
|
||||
preload: "metadata",
|
||||
};
|
||||
},
|
||||
|
||||
getThumbnailUrl(id: string, _mimeType?: string, options?: { width?: number; height?: number }) {
|
||||
// For videos, return a thumbnail/poster image
|
||||
const baseUrl = customerSubdomain
|
||||
? `https://${customerSubdomain}`
|
||||
: `https://customer-${accountId.slice(0, 8)}.cloudflarestream.com`;
|
||||
|
||||
// Stream supports thumbnail customization via URL params
|
||||
const width = options?.width || 400;
|
||||
const height = options?.height;
|
||||
let url = `${baseUrl}/${id}/thumbnails/thumbnail.jpg?width=${width}`;
|
||||
if (height) url += `&height=${height}`;
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
// Cloudflare Stream API response types
|
||||
interface CloudflareStreamListResponse {
|
||||
success: boolean;
|
||||
errors?: Array<{ message: string }>;
|
||||
result: CloudflareStreamVideo[];
|
||||
}
|
||||
|
||||
interface CloudflareStreamResponse {
|
||||
success: boolean;
|
||||
errors?: Array<{ message: string }>;
|
||||
result: CloudflareStreamVideo;
|
||||
}
|
||||
|
||||
interface CloudflareStreamDirectUploadResponse {
|
||||
success: boolean;
|
||||
errors?: Array<{ message: string }>;
|
||||
result: {
|
||||
uploadURL: string;
|
||||
uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CloudflareStreamVideo {
|
||||
uid: string;
|
||||
thumbnail: string;
|
||||
thumbnailTimestampPct?: number;
|
||||
readyToStream: boolean;
|
||||
status: {
|
||||
state: string;
|
||||
pctComplete?: string;
|
||||
errorReasonCode?: string;
|
||||
errorReasonText?: string;
|
||||
};
|
||||
meta?: Record<string, unknown>;
|
||||
created: string;
|
||||
modified: string;
|
||||
size: number;
|
||||
preview?: string;
|
||||
allowedOrigins?: string[];
|
||||
requireSignedURLs: boolean;
|
||||
uploaded?: string;
|
||||
scheduledDeletion?: string;
|
||||
input?: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
playback?: {
|
||||
hls: string;
|
||||
dash: string;
|
||||
};
|
||||
watermark?: unknown;
|
||||
duration: number;
|
||||
}
|
||||
118
packages/cloudflare/src/media/stream.ts
Normal file
118
packages/cloudflare/src/media/stream.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Cloudflare Stream Media Provider
|
||||
*
|
||||
* Provides integration with Cloudflare Stream for video hosting and streaming.
|
||||
*
|
||||
* Features:
|
||||
* - Browse uploaded videos
|
||||
* - Upload new videos (direct upload)
|
||||
* - Delete videos
|
||||
* - HLS/DASH streaming URLs
|
||||
* - Thumbnail generation
|
||||
*
|
||||
* @see https://developers.cloudflare.com/stream/
|
||||
*/
|
||||
|
||||
import type { MediaProviderDescriptor } from "emdash/media";
|
||||
|
||||
/**
|
||||
* Cloudflare Stream configuration
|
||||
*/
|
||||
export interface CloudflareStreamConfig {
|
||||
/**
|
||||
* Cloudflare Account ID
|
||||
* If not provided, reads from accountIdEnvVar at runtime
|
||||
*/
|
||||
accountId?: string;
|
||||
|
||||
/**
|
||||
* Environment variable name containing the Account ID
|
||||
* @default "CF_ACCOUNT_ID"
|
||||
*/
|
||||
accountIdEnvVar?: string;
|
||||
|
||||
/**
|
||||
* API Token with Stream permissions
|
||||
* If not provided, reads from apiTokenEnvVar at runtime
|
||||
* Should have "Stream: Read" and "Stream: Edit" permissions
|
||||
*/
|
||||
apiToken?: string;
|
||||
|
||||
/**
|
||||
* Environment variable name containing the API token
|
||||
* @default "CF_STREAM_TOKEN"
|
||||
*/
|
||||
apiTokenEnvVar?: string;
|
||||
|
||||
/**
|
||||
* Customer subdomain for Stream delivery (optional)
|
||||
* If not provided, uses customer-{hash}.cloudflarestream.com format
|
||||
*/
|
||||
customerSubdomain?: string;
|
||||
|
||||
/**
|
||||
* Default player controls setting
|
||||
* @default true
|
||||
*/
|
||||
controls?: boolean;
|
||||
|
||||
/**
|
||||
* Autoplay videos (muted by default to comply with browser policies)
|
||||
* @default false
|
||||
*/
|
||||
autoplay?: boolean;
|
||||
|
||||
/**
|
||||
* Loop videos
|
||||
* @default false
|
||||
*/
|
||||
loop?: boolean;
|
||||
|
||||
/**
|
||||
* Mute videos
|
||||
* @default false (true if autoplay is enabled)
|
||||
*/
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
// Cloudflare Stream icon (inline SVG as data URL)
|
||||
const STREAM_ICON = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" viewBox="0 0 64 64"><g clip-path="url(#a)"><path fill="#F63" d="M59.87 30.176a11.73 11.73 0 0 0-8-2.72 19.3 19.3 0 0 0-37-4.59 13.63 13.63 0 0 0-9.67 3.19 14.599 14.599 0 0 0-5.2 11 14.24 14.24 0 0 0 14.18 14.25h37.88a12 12 0 0 0 7.81-21.13Zm-7.81 17.13H14.19A10.24 10.24 0 0 1 4 37.086a10.58 10.58 0 0 1 3.77-8 9.55 9.55 0 0 1 6.23-2.25c.637 0 1.273.058 1.9.17l1.74.31.51-1.69A15.29 15.29 0 0 1 48 29.686l.1 2.32 2.26-.36a8.239 8.239 0 0 1 6.91 1.62 8.098 8.098 0 0 1 2.73 6.1 8 8 0 0 1-7.94 7.94Z"/><path fill="#F63" fill-rule="evenodd" d="m25.72 24.89 3.02-1.72 15.085 8.936.004 3.44-15.087 8.973L25.72 42.8V24.89Zm4 3.51v10.883l9.168-5.452L29.72 28.4Z" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h64v64H0z"/></clipPath></defs></svg>')}`;
|
||||
|
||||
/**
|
||||
* Cloudflare Stream media provider
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { cloudflareStream } from "@emdashcms/cloudflare";
|
||||
*
|
||||
* emdash({
|
||||
* mediaProviders: [
|
||||
* // Uses CF_ACCOUNT_ID and CF_STREAM_TOKEN env vars by default
|
||||
* cloudflareStream({}),
|
||||
*
|
||||
* // Or with custom env var names
|
||||
* cloudflareStream({
|
||||
* accountIdEnvVar: "MY_CF_ACCOUNT",
|
||||
* apiTokenEnvVar: "MY_CF_STREAM_KEY",
|
||||
* }),
|
||||
* ],
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function cloudflareStream(
|
||||
config: CloudflareStreamConfig,
|
||||
): MediaProviderDescriptor<CloudflareStreamConfig> {
|
||||
return {
|
||||
id: "cloudflare-stream",
|
||||
name: "Cloudflare Stream",
|
||||
icon: STREAM_ICON,
|
||||
entrypoint: "@emdashcms/cloudflare/media/stream-runtime",
|
||||
capabilities: {
|
||||
browse: true,
|
||||
search: true,
|
||||
upload: true,
|
||||
delete: true,
|
||||
},
|
||||
config,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user