Stream Patterns
Common workflows, full-stack flows, and best practices.
React Stream Player
npm install @cloudflare/stream-react
import { Stream } from '@cloudflare/stream-react'
export function VideoPlayer({ videoId, token }: { videoId: string; token?: string }) {
return <Stream controls src={token ? `${videoId}?token=${token}` : videoId} responsive />
}
Full-Stack Upload Flow
Backend API (Workers/Pages)
import Cloudflare from 'cloudflare'
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { videoName } = await request.json()
const client = new Cloudflare({ apiToken: env.CF_API_TOKEN })
const { uploadURL, uid } = await client.stream.directUpload.create({
account_id: env.CF_ACCOUNT_ID,
maxDurationSeconds: 3600,
requireSignedURLs: true,
meta: { name: videoName },
})
return Response.json({ uploadURL, uid })
},
}
Frontend component
import { useState } from 'react'
export function VideoUploader() {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
async function handleUpload(file: File) {
setUploading(true)
const { uploadURL, uid } = await fetch('/api/upload-url', {
method: 'POST',
body: JSON.stringify({ videoName: file.name }),
}).then((r) => r.json())
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (e) => setProgress((e.loaded / e.total) * 100)
xhr.onload = () => {
setUploading(false)
window.location.href = `/videos/${uid}`
}
xhr.open('POST', uploadURL)
const formData = new FormData()
formData.append('file', file)
xhr.send(formData)
}
return (
<div>
<input
type="file"
accept="video/*"
=> e.target.files?.[0] && handleUpload(e.target.files[0])}
disabled={uploading}
/>
{uploading && <progress value={progress} max={100} />}
</div>
)
}
TUS Resumable Upload
For large files (>500MB). npm install tus-js-client
import * as tus from 'tus-js-client'
async function uploadWithTUS(file: File, uploadURL: string, onProgress?: (pct: number) => void) {
return new Promise<string>((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: uploadURL,
retryDelays: [0, 3000, 5000, 10000, 20000],
chunkSize: 50 * 1024 * 1024,
metadata: { filename: file.name, filetype: file.type },
onError: reject,
onProgress: (up, total) => onProgress?.((up / total) * 100),
onSuccess: () => resolve(upload.url?.split('/').pop() || ''),
})
upload.start()
})
}
Video State Polling
async function waitForVideoReady(client: Cloudflare, accountId: string, videoId: string) {
for (let i = 0; i < 60; i++) {
const video = await client.stream.videos.get(videoId, { account_id: accountId })
if (video.readyToStream || video.status.state === 'error') return video
await new Promise((resolve) => setTimeout(resolve, 5000))
}
throw new Error('Video processing timeout')
}
Webhook Handler
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const signature = request.headers.get('Webhook-Signature')
const body = await request.text()
if (!signature || !(await verifyWebhook(signature, body, env.WEBHOOK_SECRET))) {
return new Response('Unauthorized', { status: 401 })
}
const payload = JSON.parse(body)
if (payload.readyToStream) console.log(`Video ${payload.uid} ready`)
return new Response('OK')
},
}
async function verifyWebhook(sig: string, body: string, secret: string): Promise<boolean> {
const parts = Object.fromEntries(sig.split(',').map((p) => p.split('=')))
const timestamp = parseInt(parts.time || '0', 10)
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const computed = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${timestamp}.${body}`))
const hex = Array.from(new Uint8Array(computed), (b) => b.toString(16).padStart(2, '0')).join('')
return hex === parts.sig1
}
Self-Sign JWT (High Volume Tokens)
For >1k tokens/day. Prerequisites: Create signing key (see configuration.md).
async function selfSignToken(keyId: string, jwkBase64: string, videoId: string, expiresIn = 3600) {
const key = await crypto.subtle.importKey(
'jwk',
JSON.parse(atob(jwkBase64)),
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign'],
)
const now = Math.floor(Date.now() / 1000)
const header = btoa(JSON.stringify({ alg: 'RS256', kid: keyId }))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
const payload = btoa(JSON.stringify({ sub: videoId, kid: keyId, exp: now + expiresIn, nbf: now }))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
const message = `${header}.${payload}`
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(message))
const b64Sig = btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
return `${message}.${b64Sig}`
}
// With access rules (geo-restriction)
const payloadWithRules = {
sub: videoId,
kid: keyId,
exp: now + 3600,
nbf: now,
accessRules: [{ type: 'ip.geoip.country', action: 'allow', country: ['US'] }],
}
Best Practices
- Use Direct Creator Uploads - Avoid proxying through servers
- Enable requireSignedURLs - Control private content access
- Self-sign tokens at scale - Use signing keys for >1k/day
- Set allowedOrigins - Prevent hotlinking
- Use webhooks over polling - Efficient status updates
- Set maxDurationSeconds - Prevent abuse
- Enable live recordings - Auto VOD after stream
In This Reference
- README.md - Overview and quick start
- configuration.md - Setup and config
- api.md - On-demand video APIs
- api-live.md - Live streaming APIs
- gotchas.md - Error codes, troubleshooting