src/lib/cloudflare-stream.ts
Annotation non disponible
Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.
9 exports
createUploadUrlgetVideoStatuslistVideosdeleteVideoverifyWebhookSignatureCFStreamUploadResponseCFStreamVideoCF_ACCOUNT_IDCF_API_TOKEN
Code source· typescript
/**
* Cloudflare Stream client - upload, status, playback, delete.
*
* Stack vidéo wari.pro V1 :
* Mobile (expo-video) → CF Stream (TUS upload + transcoding HLS + CDN) → Webhook → DB
*
* Coût : $5/mois minimum (1k min stocké + 1k min livré), scaling pay-per-use.
* Doc : https://developers.cloudflare.com/stream/
*/
const CF_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID;
const CF_API_TOKEN = process.env.CLOUDFLARE_STREAM_API_TOKEN;
const CF_WEBHOOK_SECRET = process.env.CLOUDFLARE_STREAM_WEBHOOK_SECRET;
const CF_API_BASE = "https://api.cloudflare.com/client/v4";
if (!CF_ACCOUNT_ID || !CF_API_TOKEN) {
console.warn(
"[cloudflare-stream] CF_ACCOUNT_ID ou CF_API_TOKEN absent — vidéos désactivées",
);
}
export type CFStreamUploadResponse = {
uploadURL: string; // URL TUS pour upload mobile direct
uid: string; // ID unique de la vidéo CF Stream
};
export type CFStreamVideo = {
uid: string;
thumbnail: string;
playback: { hls: string; dash: string };
duration: number;
status: { state: "queued" | "inprogress" | "ready" | "error" };
meta: Record<string, string>;
input?: { width: number; height: number };
};
async function cfFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
if (!CF_ACCOUNT_ID || !CF_API_TOKEN) {
throw new Error(
"Cloudflare Stream non configuré (CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_STREAM_API_TOKEN requis)",
);
}
const res = await fetch(`${CF_API_BASE}/accounts/${CF_ACCOUNT_ID}${path}`, {
...init,
headers: {
Authorization: `Bearer ${CF_API_TOKEN}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`CF Stream ${res.status}: ${txt.slice(0, 300)}`);
}
const data = (await res.json()) as {
result: T;
success: boolean;
errors?: unknown[];
};
if (!data.success) {
throw new Error(`CF Stream error: ${JSON.stringify(data.errors)}`);
}
return data.result;
}
/**
* Crée une URL d'upload direct depuis le mobile.
* Le mobile fait ensuite un upload vers cette URL avec le binaire vidéo.
*
* @param maxDurationSeconds - durée max enforcée par CF (rejette si plus long)
* @param meta - métadonnées optionnelles (tenantId, contexte, etc.)
*/
export async function createUploadUrl(options: {
maxDurationSeconds?: number;
meta?: Record<string, string>;
requireSignedURLs?: boolean;
}): Promise<CFStreamUploadResponse> {
const body: Record<string, unknown> = {};
if (options.maxDurationSeconds) body.maxDurationSeconds = options.maxDurationSeconds;
if (options.meta) body.meta = options.meta;
if (options.requireSignedURLs) body.requireSignedURLs = options.requireSignedURLs;
// CF Stream direct upload : on utilise le mode "direct creator upload"
// qui retourne uploadURL + uid pour upload depuis mobile.
return cfFetch<CFStreamUploadResponse>("/stream/direct_upload", {
method: "POST",
body: JSON.stringify(body),
});
}
/** Récupère le status + URLs playback d'une vidéo. */
export async function getVideoStatus(uid: string): Promise<CFStreamVideo> {
return cfFetch<CFStreamVideo>(`/stream/${uid}`);
}
/** Liste vidéos (pour cleanup batch). */
export async function listVideos(
options: { limit?: number; before?: string } = {},
): Promise<CFStreamVideo[]> {
const params = new URLSearchParams();
if (options.limit) params.set("limit", String(options.limit));
if (options.before) params.set("before", options.before);
return cfFetch<CFStreamVideo[]>(`/stream?${params.toString()}`);
}
/** Supprime une vidéo CF Stream (cleanup quand Media supprimé Prisma). */
export async function deleteVideo(uid: string): Promise<void> {
await cfFetch(`/stream/${uid}`, { method: "DELETE" }).catch(() => undefined);
}
/** Vérifie signature HMAC webhook CF Stream. */
export async function verifyWebhookSignature(
rawBody: string,
signatureHeader: string | null,
): Promise<boolean> {
if (!CF_WEBHOOK_SECRET) {
console.warn(
"[cf-stream] CLOUDFLARE_STREAM_WEBHOOK_SECRET absent — webhook signatures non vérifiées",
);
return true; // V1 dev mode : accept all
}
if (!signatureHeader) return false;
// CF Stream webhook signature format : "time={epoch},sig1={hmac}"
const parts = Object.fromEntries(
signatureHeader
.split(",")
.map((p) => p.split("=").slice(0, 2) as [string, string]),
);
const time = parts.time;
const expectedSig = parts.sig1;
if (!time || !expectedSig) return false;
const payload = `${time}.${rawBody}`;
const crypto = await import("crypto");
const computedSig = crypto
.createHmac("sha256", CF_WEBHOOK_SECRET)
.update(payload)
.digest("hex");
return computedSig === expectedSig;
}
export { CF_ACCOUNT_ID, CF_API_TOKEN };
/**
* Cloudflare Stream client - upload, status, playback, delete.
*
* Stack vidéo wari.pro V1 :
* Mobile (expo-video) → CF Stream (TUS upload + transcoding HLS + CDN) → Webhook → DB
*
* Coût : $5/mois minimum (1k min stocké + 1k min livré), scaling pay-per-use.
* Doc : https://developers.cloudflare.com/stream/
*/
const CF_ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID;
const CF_API_TOKEN = process.env.CLOUDFLARE_STREAM_API_TOKEN;
const CF_WEBHOOK_SECRET = process.env.CLOUDFLARE_STREAM_WEBHOOK_SECRET;
const CF_API_BASE = "https://api.cloudflare.com/client/v4";
if (!CF_ACCOUNT_ID || !CF_API_TOKEN) {
console.warn(
"[cloudflare-stream] CF_ACCOUNT_ID ou CF_API_TOKEN absent — vidéos désactivées",
);
}
export type CFStreamUploadResponse = {
uploadURL: string; // URL TUS pour upload mobile direct
uid: string; // ID unique de la vidéo CF Stream
};
export type CFStreamVideo = {
uid: string;
thumbnail: string;
playback: { hls: string; dash: string };
duration: number;
status: { state: "queued" | "inprogress" | "ready" | "error" };
meta: Record<string, string>;
input?: { width: number; height: number };
};
async function cfFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
if (!CF_ACCOUNT_ID || !CF_API_TOKEN) {
throw new Error(
"Cloudflare Stream non configuré (CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_STREAM_API_TOKEN requis)",
);
}
const res = await fetch(`${CF_API_BASE}/accounts/${CF_ACCOUNT_ID}${path}`, {
...init,
headers: {
Authorization: `Bearer ${CF_API_TOKEN}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`CF Stream ${res.status}: ${txt.slice(0, 300)}`);
}
const data = (await res.json()) as {
result: T;
success: boolean;
errors?: unknown[];
};
if (!data.success) {
throw new Error(`CF Stream error: ${JSON.stringify(data.errors)}`);
}
return data.result;
}
/**
* Crée une URL d'upload direct depuis le mobile.
* Le mobile fait ensuite un upload vers cette URL avec le binaire vidéo.
*
* @param maxDurationSeconds - durée max enforcée par CF (rejette si plus long)
* @param meta - métadonnées optionnelles (tenantId, contexte, etc.)
*/
export async function createUploadUrl(options: {
maxDurationSeconds?: number;
meta?: Record<string, string>;
requireSignedURLs?: boolean;
}): Promise<CFStreamUploadResponse> {
const body: Record<string, unknown> = {};
if (options.maxDurationSeconds) body.maxDurationSeconds = options.maxDurationSeconds;
if (options.meta) body.meta = options.meta;
if (options.requireSignedURLs) body.requireSignedURLs = options.requireSignedURLs;
// CF Stream direct upload : on utilise le mode "direct creator upload"
// qui retourne uploadURL + uid pour upload depuis mobile.
return cfFetch<CFStreamUploadResponse>("/stream/direct_upload", {
method: "POST",
body: JSON.stringify(body),
});
}
/** Récupère le status + URLs playback d'une vidéo. */
export async function getVideoStatus(uid: string): Promise<CFStreamVideo> {
return cfFetch<CFStreamVideo>(`/stream/${uid}`);
}
/** Liste vidéos (pour cleanup batch). */
export async function listVideos(
options: { limit?: number; before?: string } = {},
): Promise<CFStreamVideo[]> {
const params = new URLSearchParams();
if (options.limit) params.set("limit", String(options.limit));
if (options.before) params.set("before", options.before);
return cfFetch<CFStreamVideo[]>(`/stream?${params.toString()}`);
}
/** Supprime une vidéo CF Stream (cleanup quand Media supprimé Prisma). */
export async function deleteVideo(uid: string): Promise<void> {
await cfFetch(`/stream/${uid}`, { method: "DELETE" }).catch(() => undefined);
}
/** Vérifie signature HMAC webhook CF Stream. */
export async function verifyWebhookSignature(
rawBody: string,
signatureHeader: string | null,
): Promise<boolean> {
if (!CF_WEBHOOK_SECRET) {
console.warn(
"[cf-stream] CLOUDFLARE_STREAM_WEBHOOK_SECRET absent — webhook signatures non vérifiées",
);
return true; // V1 dev mode : accept all
}
if (!signatureHeader) return false;
// CF Stream webhook signature format : "time={epoch},sig1={hmac}"
const parts = Object.fromEntries(
signatureHeader
.split(",")
.map((p) => p.split("=").slice(0, 2) as [string, string]),
);
const time = parts.time;
const expectedSig = parts.sig1;
if (!time || !expectedSig) return false;
const payload = `${time}.${rawBody}`;
const crypto = await import("crypto");
const computedSig = crypto
.createHmac("sha256", CF_WEBHOOK_SECRET)
.update(payload)
.digest("hex");
return computedSig === expectedSig;
}
export { CF_ACCOUNT_ID, CF_API_TOKEN };