first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View 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;
};
}

View 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,
};
}

View 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;
}

View 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,
};
}