Skip to content

Tutorial Implementation of Live Streaming with WebRTC and Cloudflare Streams

License

Notifications You must be signed in to change notification settings

Atyantik/livestreaming-webrtc-cloudflare-tutorial

Repository files navigation

Build a Live Streaming App with Cloudflare Stream WebRTC

A beginner-friendly tutorial for building a real-time live streaming application using Astro, Cloudflare Workers, and Cloudflare Stream WebRTC (WHIP/WHEP).

What You'll Build

A complete live streaming application where:

  • Broadcasters can go live with one click, sharing their camera and microphone
  • Viewers can watch streams in real-time with sub-second latency
  • Everything runs on Cloudflare's global edge network

Live Stream Demo


Table of Contents

  1. Prerequisites
  2. How It Works
  3. Project Structure
  4. Step 1: Project Setup
  5. Step 2: Configuration Files
  6. Step 3: TypeScript Types
  7. Step 4: Cloudflare Stream API
  8. Step 5: WHIP Client (Broadcasting)
  9. Step 6: WHEP Client (Viewing)
  10. Step 7: API Endpoints
  11. Step 8: Frontend Pages
  12. Step 9: Local Development
  13. Step 10: Deployment
  14. Troubleshooting
  15. Further Reading
  16. Presentation
  17. Credits & Acknowledgments
  18. Connect With Us
  19. Contributing
  20. License

Prerequisites

Before starting, you'll need:

  • Node.js 18+ installed (download here)
  • A Cloudflare account (sign up free)
  • Cloudflare Stream enabled on your account
  • Basic knowledge of JavaScript/TypeScript and HTML

Getting Your Cloudflare Credentials

  1. Log into Cloudflare Dashboard
  2. Copy your Account ID from the right sidebar on the overview page
  3. Go to My ProfileAPI TokensCreate Token
  4. Use the Edit Cloudflare Stream template or create a custom token with:
    • Permissions: Stream:Edit
  5. Copy the generated API token (you won't see it again!)

How It Works

This app uses two WebRTC protocols standardized by the IETF:

WHIP (WebRTC-HTTP Ingestion Protocol)

  • Used by the broadcaster to send video/audio TO Cloudflare
  • Browser captures camera/mic → Creates WebRTC connection → Sends to WHIP URL

WHEP (WebRTC-HTTP Egress Protocol)

  • Used by viewers to receive video/audio FROM Cloudflare
  • Browser connects to WHEP URL → Receives WebRTC stream → Displays video
┌─────────────┐         WHIP          ┌─────────────────┐         WHEP          ┌─────────────┐
│ Broadcaster │ ───────────────────▶  │ Cloudflare Edge │  ◀─────────────────── │   Viewer    │
│  (Camera)   │   WebRTC Publish      │    (Stream)     │    WebRTC Playback    │  (Watch)    │
└─────────────┘                       └─────────────────┘                       └─────────────┘

Why WebRTC?

  • Sub-second latency (< 1 second delay)
  • No plugins required (works in all modern browsers)
  • Scales to unlimited viewers via Cloudflare's network

Project Structure

going-live-with-cloudflare/
├── astro.config.mjs        # Astro framework configuration
├── tailwind.config.mjs     # Tailwind CSS styling configuration
├── wrangler.jsonc          # Cloudflare Workers deployment config
├── package.json            # Project dependencies
├── tsconfig.json           # TypeScript configuration
│
├── public/
│   ├── favicon.svg         # Site icon
│   └── .assetsignore       # Files to exclude from upload
│
└── src/
    ├── env.d.ts            # TypeScript type definitions
    │
    ├── styles/
    │   └── global.css      # Tailwind CSS imports
    │
    ├── layouts/
    │   └── Layout.astro    # Base HTML template
    │
    ├── lib/                        # Core library code
    │   ├── cloudflare-stream.ts    # Cloudflare API wrapper
    │   ├── whip-client.ts          # Broadcasting client
    │   └── whep-client.ts          # Viewing client
    │
    └── pages/                      # Routes & API endpoints
        ├── index.astro             # Homepage (/)
        ├── broadcast/
        │   └── [streamId].astro    # Broadcast page (/broadcast/xxx)
        ├── watch/
        │   └── [streamId].astro    # Viewer page (/watch/xxx)
        └── api/stream/
            ├── create.ts           # POST /api/stream/create
            ├── urls.ts             # POST /api/stream/urls
            └── [streamId]/
                ├── status.ts       # GET /api/stream/xxx/status
                └── stop.ts         # POST /api/stream/xxx/stop

Step 1: Project Setup

Create Project Directory

mkdir going-live-with-cloudflare
cd going-live-with-cloudflare

Initialize package.json

Create package.json:

{
  "name": "going-live-with-cloudflare",
  "type": "module",
  "version": "0.0.1",
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "deploy": "astro build && wrangler deploy"
  }
}

Install Dependencies

# Core dependencies
npm install astro @astrojs/cloudflare @astrojs/tailwind tailwindcss

# Development tools
npm install -D wrangler

What each package does:

  • astro - The web framework for building the app
  • @astrojs/cloudflare - Adapter to deploy Astro on Cloudflare Workers
  • @astrojs/tailwind - Tailwind CSS integration for Astro
  • tailwindcss - Utility-first CSS framework
  • wrangler - Cloudflare's CLI tool for deployment

Step 2: Configuration Files

astro.config.mjs

This configures Astro to work with Cloudflare:

import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import tailwind from '@astrojs/tailwind';

export default defineConfig({
  // Enable server-side rendering (required for API routes)
  output: 'server',

  // Configure Cloudflare adapter
  adapter: cloudflare({
    // Enable local development with Cloudflare bindings
    platformProxy: {
      enabled: true
    },
    // Use passthrough for images (sharp not supported on Cloudflare)
    imageService: 'passthrough'
  }),

  // Enable Tailwind CSS
  integrations: [tailwind()]
});

Key concepts:

  • output: 'server' - Enables server-side rendering so we can have API endpoints
  • platformProxy - Lets us test Cloudflare bindings (like secrets) locally
  • imageService: 'passthrough' - Cloudflare Workers don't support image processing

tailwind.config.mjs

Custom colors matching Cloudflare's brand:

/** @type {import('tailwindcss').Config} */
export default {
  // Tell Tailwind which files to scan for classes
  content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],

  theme: {
    extend: {
      // Custom Cloudflare brand colors
      colors: {
        'cf-orange': '#f6821f',      // Primary orange
        'cf-orange-dark': '#e5711f', // Hover state
        'cf-dark': '#1d1d1d',        // Dark background
        'cf-darker': '#0d0d0d',      // Darker background
        'cf-gray': '#2d2d2d',        // Card background
        'cf-light-gray': '#404040'   // Input background
      }
    }
  },
  plugins: []
};

wrangler.jsonc

Cloudflare Workers deployment configuration:

{
  "$schema": "https://json.schemastore.org/wrangler.json",

  // Your worker's name (becomes part of the URL)
  "name": "going-live-with-cloudflare",

  // Entry point for the worker
  "main": "dist/_worker.js/index.js",

  // Compatibility date (use a recent date)
  "compatibility_date": "2025-01-01",

  // Enable Node.js compatibility
  "compatibility_flags": ["nodejs_compat"],

  // Static assets configuration
  "assets": {
    "binding": "ASSETS",
    "directory": "./dist"
  }

  // Secrets are added via CLI:
  // wrangler secret put CF_ACCOUNT_ID
  // wrangler secret put CF_API_TOKEN
}

tsconfig.json

TypeScript configuration:

{
  "extends": "astro/tsconfigs/strict",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

Step 3: TypeScript Types

src/env.d.ts

Define types for Cloudflare environment variables:

/// <reference path="../.astro/types.d.ts" />

// Define the environment variables available in Cloudflare Workers
interface CloudflareEnv {
  CF_ACCOUNT_ID: string;   // Your Cloudflare account ID
  CF_API_TOKEN: string;    // API token with Stream permissions
  ASSETS: Fetcher;         // Static asset handler
}

// Import the Runtime type from the Cloudflare adapter
type Runtime = import('@astrojs/cloudflare').Runtime<CloudflareEnv>;

// Extend Astro's locals with our Cloudflare runtime
declare namespace App {
  interface Locals extends Runtime {}
}

Why this matters:

  • Provides TypeScript autocomplete for locals.runtime.env
  • Catches errors at build time if you mistype variable names

Step 4: Cloudflare Stream API

src/lib/cloudflare-stream.ts

This wrapper handles all communication with Cloudflare's Stream API:

// Type definitions for API responses
export interface LiveInputResult {
  uid: string;                    // Unique stream identifier
  created: string;                // ISO timestamp
  modified: string;               // ISO timestamp
  meta: { name: string };         // Custom metadata
  webRTC: {
    url: string;                  // WHIP URL for broadcasting
  };
  webRTCPlayback: {
    url: string;                  // WHEP URL for viewing
  };
  status: string | null;          // Connection status
}

export interface LiveInputResponse {
  success: boolean;
  errors: Array<{ code: number; message: string }>;
  result: LiveInputResult;
}

export class CloudflareStreamAPI {
  private accountId: string;
  private apiToken: string;
  private baseUrl: string;

  constructor(accountId: string, apiToken: string) {
    this.accountId = accountId;
    this.apiToken = apiToken;
    // Cloudflare Stream API base URL
    this.baseUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}/stream`;
  }

  /**
   * Create a new live input (stream)
   * Returns WHIP and WHEP URLs for broadcasting and viewing
   */
  async createLiveInput(name: string): Promise<LiveInputResponse> {
    const response = await fetch(`${this.baseUrl}/live_inputs`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.apiToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        meta: { name },  // Optional metadata
      }),
    });

    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(`Failed to create live input: ${response.status} ${errorText}`);
    }

    return response.json();
  }

  /**
   * Get details about an existing live input
   */
  async getLiveInput(uid: string): Promise<LiveInputResponse> {
    const response = await fetch(`${this.baseUrl}/live_inputs/${uid}`, {
      method: 'GET',
      headers: {
        'Authorization': `Bearer ${this.apiToken}`,
      },
    });

    if (!response.ok) {
      throw new Error(`Failed to get live input: ${response.status}`);
    }

    return response.json();
  }

  /**
   * Delete a live input (ends the stream)
   */
  async deleteLiveInput(uid: string): Promise<void> {
    const response = await fetch(`${this.baseUrl}/live_inputs/${uid}`, {
      method: 'DELETE',
      headers: {
        'Authorization': `Bearer ${this.apiToken}`,
      },
    });

    if (!response.ok) {
      throw new Error(`Failed to delete live input: ${response.status}`);
    }
  }
}

API Flow:

  1. createLiveInput() → Returns stream ID + WHIP/WHEP URLs
  2. Broadcaster uses WHIP URL to publish
  3. Viewers use WHEP URL to watch
  4. deleteLiveInput() → Ends the stream

Step 5: WHIP Client (Broadcasting)

src/lib/whip-client.ts

The WHIP client captures camera/microphone and sends it to Cloudflare:

export class WHIPClient {
  private peerConnection: RTCPeerConnection | null = null;
  private mediaStream: MediaStream | null = null;
  private whipUrl: string;
  private resourceUrl: string | null = null;

  constructor(whipUrl: string) {
    this.whipUrl = whipUrl;
  }

  /**
   * Start broadcasting to the WHIP endpoint
   */
  async connect(videoElement: HTMLVideoElement): Promise<void> {
    // Step 1: Get camera and microphone access
    this.mediaStream = await navigator.mediaDevices.getUserMedia({
      video: {
        width: { ideal: 1280 },   // 720p resolution
        height: { ideal: 720 },
        frameRate: { ideal: 30 }  // 30 FPS
      },
      audio: true
    });

    // Show local preview
    videoElement.srcObject = this.mediaStream;

    // Step 2: Create WebRTC peer connection
    this.peerConnection = new RTCPeerConnection({
      iceServers: [],           // Cloudflare handles ICE
      bundlePolicy: 'max-bundle' // Optimize for single connection
    });

    // Step 3: Add camera/mic tracks to the connection
    this.mediaStream.getTracks().forEach(track => {
      this.peerConnection!.addTrack(track, this.mediaStream!);
    });

    // Step 4: Create SDP offer (describes what we want to send)
    const offer = await this.peerConnection.createOffer();
    await this.peerConnection.setLocalDescription(offer);

    // Step 5: Wait for ICE candidates to be gathered
    await this.waitForIceGathering();

    // Step 6: Send offer to WHIP endpoint
    const response = await fetch(this.whipUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/sdp',
      },
      body: this.peerConnection.localDescription!.sdp,
    });

    if (response.status !== 201) {
      const errorText = await response.text();
      throw new Error(`WHIP negotiation failed: ${response.status} - ${errorText}`);
    }

    // Save resource URL for cleanup
    this.resourceUrl = response.headers.get('Location');

    // Step 7: Apply the answer from Cloudflare
    const answerSdp = await response.text();
    await this.peerConnection.setRemoteDescription({
      type: 'answer',
      sdp: answerSdp
    });
  }

  /**
   * Wait for ICE gathering to complete
   * ICE (Interactive Connectivity Establishment) finds the best network path
   */
  private waitForIceGathering(): Promise<void> {
    return new Promise((resolve) => {
      if (this.peerConnection!.iceGatheringState === 'complete') {
        resolve();
        return;
      }

      const checkState = () => {
        if (this.peerConnection!.iceGatheringState === 'complete') {
          this.peerConnection!.removeEventListener('icegatheringstatechange', checkState);
          resolve();
        }
      };

      this.peerConnection!.addEventListener('icegatheringstatechange', checkState);

      // Timeout after 5 seconds
      setTimeout(() => resolve(), 5000);
    });
  }

  /**
   * Stop broadcasting and clean up resources
   */
  async disconnect(): Promise<void> {
    // Stop all media tracks
    if (this.mediaStream) {
      this.mediaStream.getTracks().forEach(track => track.stop());
      this.mediaStream = null;
    }

    // Close peer connection
    if (this.peerConnection) {
      this.peerConnection.close();
      this.peerConnection = null;
    }

    // Notify server that we're done
    if (this.resourceUrl) {
      try {
        await fetch(this.resourceUrl, { method: 'DELETE' });
      } catch {
        // Ignore cleanup errors
      }
      this.resourceUrl = null;
    }
  }

  /**
   * Get current connection state for UI updates
   */
  getConnectionState(): RTCPeerConnectionState | null {
    return this.peerConnection?.connectionState ?? null;
  }
}

WebRTC Concepts Explained:

  1. MediaStream - Contains audio/video tracks from camera/mic
  2. RTCPeerConnection - Manages the WebRTC connection
  3. SDP (Session Description Protocol) - Describes media capabilities
  4. ICE (Interactive Connectivity Establishment) - Finds network path
  5. Offer/Answer - Two-way handshake to establish connection

Step 6: WHEP Client (Viewing)

src/lib/whep-client.ts

The WHEP client receives the stream and displays it:

export class WHEPClient {
  private peerConnection: RTCPeerConnection | null = null;
  private whepUrl: string;
  private resourceUrl: string | null = null;
  private mediaStream: MediaStream | null = null;

  constructor(whepUrl: string) {
    this.whepUrl = whepUrl;
  }

  /**
   * Connect to the WHEP endpoint and start receiving video
   */
  async connect(videoElement: HTMLVideoElement): Promise<void> {
    // Step 1: Create peer connection
    this.peerConnection = new RTCPeerConnection({
      iceServers: [],
      bundlePolicy: 'max-bundle'
    });

    // Step 2: Create MediaStream to collect incoming tracks
    // This is important! Tracks may arrive separately
    this.mediaStream = new MediaStream();
    videoElement.srcObject = this.mediaStream;

    // Step 3: Add transceivers for receiving (not sending)
    this.peerConnection.addTransceiver('video', { direction: 'recvonly' });
    this.peerConnection.addTransceiver('audio', { direction: 'recvonly' });

    // Step 4: Handle incoming tracks
    this.peerConnection.addEventListener('track', (event) => {
      // Add each track to our media stream
      if (event.track) {
        this.mediaStream!.addTrack(event.track);
      }
    });

    // Step 5: Create and send offer
    const offer = await this.peerConnection.createOffer();
    await this.peerConnection.setLocalDescription(offer);

    await this.waitForIceGathering();

    // Step 6: Send to WHEP endpoint
    const response = await fetch(this.whepUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/sdp',
      },
      body: this.peerConnection.localDescription!.sdp,
    });

    if (response.status !== 201) {
      const errorText = await response.text();
      throw new Error(`WHEP negotiation failed: ${response.status} - ${errorText}`);
    }

    this.resourceUrl = response.headers.get('Location');

    // Step 7: Apply answer
    const answerSdp = await response.text();
    await this.peerConnection.setRemoteDescription({
      type: 'answer',
      sdp: answerSdp
    });
  }

  private waitForIceGathering(): Promise<void> {
    return new Promise((resolve) => {
      if (this.peerConnection!.iceGatheringState === 'complete') {
        resolve();
        return;
      }

      const checkState = () => {
        if (this.peerConnection!.iceGatheringState === 'complete') {
          this.peerConnection!.removeEventListener('icegatheringstatechange', checkState);
          resolve();
        }
      };

      this.peerConnection!.addEventListener('icegatheringstatechange', checkState);
      setTimeout(() => resolve(), 5000);
    });
  }

  async disconnect(): Promise<void> {
    if (this.mediaStream) {
      this.mediaStream.getTracks().forEach(track => track.stop());
      this.mediaStream = null;
    }

    if (this.peerConnection) {
      this.peerConnection.close();
      this.peerConnection = null;
    }

    if (this.resourceUrl) {
      try {
        await fetch(this.resourceUrl, { method: 'DELETE' });
      } catch {
        // Ignore cleanup errors
      }
      this.resourceUrl = null;
    }
  }

  getConnectionState(): RTCPeerConnectionState | null {
    return this.peerConnection?.connectionState ?? null;
  }
}

Key Difference from WHIP:

  • WHIP: addTrack() to send media
  • WHEP: addTransceiver('recvonly') to receive media

Step 7: API Endpoints

API endpoints run on Cloudflare Workers and handle server-side logic.

src/pages/api/stream/create.ts

Creates a new stream when user clicks "Go Live":

import type { APIRoute } from 'astro';
import { CloudflareStreamAPI } from '../../../lib/cloudflare-stream';

export const POST: APIRoute = async ({ locals }) => {
  // Access Cloudflare environment variables
  const env = locals.runtime.env;

  // Validate credentials exist
  if (!env.CF_ACCOUNT_ID || !env.CF_API_TOKEN) {
    return new Response(JSON.stringify({
      error: 'Server configuration error: Missing Cloudflare credentials'
    }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    });
  }

  const api = new CloudflareStreamAPI(env.CF_ACCOUNT_ID, env.CF_API_TOKEN);

  try {
    // Create unique stream name
    const streamName = `stream-${Date.now()}`;
    const response = await api.createLiveInput(streamName);

    if (!response.success) {
      return new Response(JSON.stringify({
        error: 'Failed to create stream',
        details: response.errors
      }), {
        status: 500,
        headers: { 'Content-Type': 'application/json' }
      });
    }

    // Return stream ID and redirect URL
    return new Response(JSON.stringify({
      streamId: response.result.uid,
      redirectUrl: `/broadcast/${response.result.uid}`
    }), {
      status: 201,
      headers: { 'Content-Type': 'application/json' }
    });
  } catch (error) {
    console.error('Stream creation error:', error);
    return new Response(JSON.stringify({
      error: error instanceof Error ? error.message : 'Internal server error'
    }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    });
  }
};

src/pages/api/stream/urls.ts

Returns WHIP or WHEP URL for a stream:

import type { APIRoute } from 'astro';
import { CloudflareStreamAPI } from '../../../lib/cloudflare-stream';

export const POST: APIRoute = async ({ request, locals }) => {
  const env = locals.runtime.env;

  if (!env.CF_ACCOUNT_ID || !env.CF_API_TOKEN) {
    return new Response(JSON.stringify({
      error: 'Server configuration error'
    }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    });
  }

  // Parse request body
  let body: { streamId?: string; type?: string };
  try {
    body = await request.json();
  } catch {
    return new Response(JSON.stringify({
      error: 'Invalid JSON body'
    }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' }
    });
  }

  const { streamId, type } = body;

  // Validate parameters
  if (!streamId || !type || !['whip', 'whep'].includes(type)) {
    return new Response(JSON.stringify({
      error: 'Invalid request: streamId and type (whip|whep) required'
    }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' }
    });
  }

  const api = new CloudflareStreamAPI(env.CF_ACCOUNT_ID, env.CF_API_TOKEN);

  try {
    const response = await api.getLiveInput(streamId);

    if (!response.success) {
      return new Response(JSON.stringify({
        error: 'Stream not found'
      }), {
        status: 404,
        headers: { 'Content-Type': 'application/json' }
      });
    }

    // Return the appropriate URL based on type
    const url = type === 'whip'
      ? response.result.webRTC.url      // For broadcasting
      : response.result.webRTCPlayback.url;  // For viewing

    return new Response(JSON.stringify({ url }), {
      status: 200,
      headers: { 'Content-Type': 'application/json' }
    });
  } catch (error) {
    return new Response(JSON.stringify({
      error: 'Failed to fetch stream URLs'
    }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    });
  }
};

Security Note: The WHIP URL is essentially a password - anyone with it can broadcast to your stream. That's why we fetch URLs server-side and only give the WHIP URL to the broadcaster.


Step 8: Frontend Pages

src/layouts/Layout.astro

Base template for all pages:

---
// Import global styles
import '../styles/global.css';

interface Props {
  title: string;
}

const { title } = Astro.props;
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Live streaming with Cloudflare Stream WebRTC" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <title>{title}</title>
  </head>
  <body class="bg-cf-darker text-white min-h-screen">
    <slot />
  </body>
</html>

src/pages/index.astro

Homepage with "Go Live" button:

---
import Layout from '../layouts/Layout.astro';
---

<Layout title="Go Live with Cloudflare">
  <div class="min-h-screen flex flex-col items-center justify-center p-8">
    <div class="text-center max-w-md">
      <!-- Logo/Icon -->
      <div class="mb-8">
        <svg class="w-16 h-16 mx-auto text-cf-orange" viewBox="0 0 24 24" fill="currentColor">
          <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
        </svg>
      </div>

      <h1 class="text-4xl font-bold mb-4">Go Live</h1>
      <p class="text-gray-400 mb-8 text-lg">
        Start streaming instantly with sub-second latency powered by Cloudflare Stream
      </p>

      <!-- Go Live Button -->
      <button
        id="go-live-btn"
        class="bg-cf-orange hover:bg-cf-orange-dark text-white font-bold py-4 px-8 rounded-lg text-xl transition-all duration-200 transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
      >
        <span id="btn-text">Go Live</span>
        <span id="btn-loading" class="hidden">
          <!-- Loading spinner -->
          <svg class="animate-spin h-6 w-6 inline mr-2" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
          </svg>
          Creating stream...
        </span>
      </button>

      <p id="error-message" class="text-red-500 mt-4 hidden"></p>
    </div>
  </div>
</Layout>

<script>
  // Get DOM elements
  const btn = document.getElementById('go-live-btn') as HTMLButtonElement;
  const btnText = document.getElementById('btn-text')!;
  const btnLoading = document.getElementById('btn-loading')!;
  const errorMessage = document.getElementById('error-message')!;

  btn.addEventListener('click', async () => {
    // Show loading state
    btn.disabled = true;
    btnText.classList.add('hidden');
    btnLoading.classList.remove('hidden');
    errorMessage.classList.add('hidden');

    try {
      // Call API to create stream
      const response = await fetch('/api/stream/create', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' }
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || 'Failed to create stream');
      }

      // Redirect to broadcast page
      window.location.href = data.redirectUrl;
    } catch (error) {
      // Show error
      errorMessage.textContent = error instanceof Error ? error.message : 'An error occurred';
      errorMessage.classList.remove('hidden');

      // Reset button
      btn.disabled = false;
      btnText.classList.remove('hidden');
      btnLoading.classList.add('hidden');
    }
  });
</script>

Step 9: Local Development

Create Environment Variables

Create .dev.vars in your project root (this file is gitignored):

CF_ACCOUNT_ID=your_cloudflare_account_id
CF_API_TOKEN=your_api_token_here

Start Development Server

npm run dev

Visit http://localhost:4321 to test your app.

Testing the Flow

  1. Click "Go Live" - Creates a stream and redirects to broadcast page
  2. Allow camera/microphone - Browser will prompt for permission
  3. Copy the viewer URL - Share with others
  4. Open viewer URL - In another browser/tab to watch

Step 10: Deployment

Set Production Secrets

# Add your Cloudflare Account ID
npx wrangler secret put CF_ACCOUNT_ID
# Paste your account ID when prompted

# Add your API Token
npx wrangler secret put CF_API_TOKEN
# Paste your token when prompted

Deploy

npm run deploy

Your app will be deployed to: https://going-live-with-cloudflare.<your-subdomain>.workers.dev


Troubleshooting

"Failed to create stream"

  • Check that your API token has Stream:Edit permissions
  • Verify CF_ACCOUNT_ID and CF_API_TOKEN are set correctly

Camera/Microphone not working

  • Ensure you're on HTTPS (or localhost)
  • Check browser permissions in site settings
  • Try a different browser

Video not showing (audio only)

  • This was fixed by creating a MediaStream and adding tracks individually
  • Make sure you're using the updated WHEP client code

Blank page on /watch/[streamId]

  • Check browser console for errors
  • Ensure Tailwind CSS is properly imported in Layout.astro

CORS errors

  • The Cloudflare Stream API handles CORS automatically
  • If testing locally, ensure platformProxy is enabled

Further Reading


Presentation

This project includes a Marp presentation that explains the concepts, architecture, and technology choices.

Location

presentation/
├── slides.md       # The presentation (Marp Markdown)
├── theme.css       # Custom Cloudflare x Atyantik theme
├── package.json    # Build scripts
└── README.md       # Presentation guide

Preview Presentation

# Option 1: Using npx (no install needed)
cd presentation
npx @marp-team/marp-cli slides.md --preview

# Option 2: Install and run
cd presentation
npm install
npm run preview

This opens the presentation in your browser with live reload.

Export Presentation

cd presentation

# Export to HTML (for web viewing)
npx @marp-team/marp-cli slides.md --html -o dist/slides.html

# Export to PDF (for printing/sharing)
npx @marp-team/marp-cli slides.md --pdf -o dist/slides.pdf

# Export to PowerPoint (for editing)
npx @marp-team/marp-cli slides.md --pptx -o dist/slides.pptx

VS Code Extension

For the best editing experience, install the Marp for VS Code extension. It provides:

  • Live preview while editing
  • Export options from the command palette
  • Syntax highlighting for Marp directives

Credits & Acknowledgments

Built with Love by Atyantik Technologies

Atyantik Technologies

Delivering Excellence | Code. Innovate. Deliver

This project is built and maintained by Atyantik Technologies Private Limited, a software company dedicated to delivering innovative, scalable, and high-quality IT solutions.

We believe in giving back to the community. Open source is at the heart of what we do, and we encourage everyone to contribute, learn, and build amazing things together.

Special Thanks

  • Cloudflare - For providing the incredible Stream WebRTC infrastructure that makes sub-second latency streaming possible
  • Astro - For the fantastic web framework that makes building fast websites a joy
  • Marp - For the presentation framework used in this project's slides

Connect With Us

GitHub LinkedIn Twitter Website

Get in Touch

Contributing

We welcome contributions! Whether it's:

  • Reporting bugs
  • Suggesting new features
  • Improving documentation
  • Adding translations
  • Writing tutorials

Feel free to open an issue or submit a pull request.


Star History

If you find this project helpful, please consider giving it a star! It helps others discover it too.

Star this repo


License

This project is licensed under the MIT License - see the LICENSE file for details.

Copyright (c) 2025 Atyantik Technologies Private Limited


Made with ❤️ by Atyantik Technologies

About

Tutorial Implementation of Live Streaming with WebRTC and Cloudflare Streams

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published