A beginner-friendly tutorial for building a real-time live streaming application using Astro, Cloudflare Workers, and Cloudflare Stream WebRTC (WHIP/WHEP).
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
- Prerequisites
- How It Works
- Project Structure
- Step 1: Project Setup
- Step 2: Configuration Files
- Step 3: TypeScript Types
- Step 4: Cloudflare Stream API
- Step 5: WHIP Client (Broadcasting)
- Step 6: WHEP Client (Viewing)
- Step 7: API Endpoints
- Step 8: Frontend Pages
- Step 9: Local Development
- Step 10: Deployment
- Troubleshooting
- Further Reading
- Presentation
- Credits & Acknowledgments
- Connect With Us
- Contributing
- License
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
- Log into Cloudflare Dashboard
- Copy your Account ID from the right sidebar on the overview page
- Go to My Profile → API Tokens → Create Token
- Use the Edit Cloudflare Stream template or create a custom token with:
- Permissions: Stream:Edit
- Copy the generated API token (you won't see it again!)
This app uses two WebRTC protocols standardized by the IETF:
- Used by the broadcaster to send video/audio TO Cloudflare
- Browser captures camera/mic → Creates WebRTC connection → Sends to WHIP URL
- 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
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
mkdir going-live-with-cloudflare
cd going-live-with-cloudflareCreate 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"
}
}# Core dependencies
npm install astro @astrojs/cloudflare @astrojs/tailwind tailwindcss
# Development tools
npm install -D wranglerWhat 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 Astrotailwindcss- Utility-first CSS frameworkwrangler- Cloudflare's CLI tool for deployment
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 endpointsplatformProxy- Lets us test Cloudflare bindings (like secrets) locallyimageService: 'passthrough'- Cloudflare Workers don't support image processing
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: []
};Cloudflare Workers deployment configuration:
TypeScript configuration:
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}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
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:
createLiveInput()→ Returns stream ID + WHIP/WHEP URLs- Broadcaster uses WHIP URL to publish
- Viewers use WHEP URL to watch
deleteLiveInput()→ Ends the stream
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:
- MediaStream - Contains audio/video tracks from camera/mic
- RTCPeerConnection - Manages the WebRTC connection
- SDP (Session Description Protocol) - Describes media capabilities
- ICE (Interactive Connectivity Establishment) - Finds network path
- Offer/Answer - Two-way handshake to establish connection
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
API endpoints run on Cloudflare Workers and handle server-side logic.
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' }
});
}
};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.
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>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>Create .dev.vars in your project root (this file is gitignored):
CF_ACCOUNT_ID=your_cloudflare_account_id
CF_API_TOKEN=your_api_token_herenpm run devVisit http://localhost:4321 to test your app.
- Click "Go Live" - Creates a stream and redirects to broadcast page
- Allow camera/microphone - Browser will prompt for permission
- Copy the viewer URL - Share with others
- Open viewer URL - In another browser/tab to watch
# 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 promptednpm run deployYour app will be deployed to:
https://going-live-with-cloudflare.<your-subdomain>.workers.dev
- Check that your API token has Stream:Edit permissions
- Verify CF_ACCOUNT_ID and CF_API_TOKEN are set correctly
- Ensure you're on HTTPS (or localhost)
- Check browser permissions in site settings
- Try a different browser
- This was fixed by creating a MediaStream and adding tracks individually
- Make sure you're using the updated WHEP client code
- Check browser console for errors
- Ensure Tailwind CSS is properly imported in Layout.astro
- The Cloudflare Stream API handles CORS automatically
- If testing locally, ensure platformProxy is enabled
- Cloudflare Stream WebRTC Documentation
- WHIP/WHEP Specification
- Astro Documentation
- Cloudflare Workers Documentation
- WebRTC API (MDN)
This project includes a Marp presentation that explains the concepts, architecture, and technology choices.
presentation/
├── slides.md # The presentation (Marp Markdown)
├── theme.css # Custom Cloudflare x Atyantik theme
├── package.json # Build scripts
└── README.md # Presentation guide
# 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 previewThis opens the presentation in your browser with live reload.
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.pptxFor 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
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.
- 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
- Email: contact@atyantik.com
- Website: https://atyantik.com
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.
If you find this project helpful, please consider giving it a star! It helps others discover it too.
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

{ "$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 }