React quickstart
Three reusable hooks and one shared SDK init cover ~90% of what your app needs.
On this page
1. Install
npm install @kardolive/sdk mediasoup-client hls.js
Two of those are optional:
mediasoup-client— needed only if you do video/conferences/callshls.js— needed only for HLS viewers (Safari plays HLS natively)
2. KardoliveProvider — one-line SDK init
Put this at the top of your React app. It exposes useKardolive() everywhere
and wires automatic token refresh.
// your-app/apps/web/src/kardolive/KardoliveProvider.tsx
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { Kardolive } from '@kardolive/sdk';
type Ctx = {
/** Fetches a chat token from your backend. */
mintChatToken: (channelId?: string) => Promise<{ token: string; chatUrl: string }>;
/** Fetches a session token (live/conference/call). */
mintSessionToken: (sessionId: string, role?: 'host' | 'guest') =>
Promise<{ token: string; sfuUrl: string; chatUrl: string; role: string; perms: any }>;
};
const KardoliveCtx = createContext<Ctx | null>(null);
export function KardoliveProvider({ children }: { children: React.ReactNode }) {
const value = useMemo<Ctx>(() => ({
mintChatToken: async (channelId) => {
const r = await fetch('/api/kardolive/chat-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId }),
});
if (!r.ok) throw new Error('chat token mint failed');
return r.json();
},
mintSessionToken: async (sessionId, role = 'guest') => {
const r = await fetch('/api/kardolive/session-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, role }),
});
if (!r.ok) throw new Error('session token mint failed');
return r.json();
},
}), []);
return <KardoliveCtx.Provider value={value}>{children}</KardoliveCtx.Provider>;
}
export function useKardolive() {
const ctx = useContext(KardoliveCtx);
if (!ctx) throw new Error('Wrap your app in <KardoliveProvider>');
return ctx;
}
Wrap your root:
// your-app/apps/web/src/App.tsx
import { KardoliveProvider } from './kardolive/KardoliveProvider';
export default function App() {
return (
<KardoliveProvider>
<Router>{/* …rest of your app… */}</Router>
</KardoliveProvider>
);
}
useChat(channelId) hook
Connects to a chat channel, exposes messages, send(), typing,
and unread state. Auto-reconnects.
// your-app/apps/web/src/kardolive/useChat.ts
import { useEffect, useRef, useState } from 'react';
import { useKardolive } from './KardoliveProvider';
type Msg = { id: string; authorId: string; authorName: string; body: string; at: string; parentId?: string };
export function useChat(channelId: string) {
const { mintChatToken } = useKardolive();
const wsRef = useRef<WebSocket | null>(null);
const [messages, setMessages] = useState<Msg[]>([]);
const [typing, setTyping] = useState<string[]>([]);
const [state, setState] = useState<'connecting' | 'open' | 'closed'>('connecting');
useEffect(() => {
let cancelled = false;
let ws: WebSocket | null = null;
async function connect() {
const { token, chatUrl } = await mintChatToken(channelId);
if (cancelled) return;
ws = new WebSocket(`${chatUrl}/v1/chat/socket?room=${channelId}&token=${token}`);
wsRef.current = ws;
ws.onopen = () => setState('open');
ws.onmessage = (e) => {
const env = JSON.parse(e.data);
if (env.type === 'chat') setMessages((m) => [...m, env.data]);
if (env.type === 'typing') {
setTyping((t) => env.data.on
? Array.from(new Set([...t, env.data.name]))
: t.filter((n) => n !== env.data.name));
}
};
ws.onclose = () => {
setState('closed');
if (!cancelled) setTimeout(connect, 2000); // simple reconnect
};
}
connect();
return () => { cancelled = true; ws?.close(); };
}, [channelId, mintChatToken]);
function send(body: string) {
wsRef.current?.send(JSON.stringify({ type: 'chat.send', data: { body } }));
}
function sendTyping(on: boolean) {
wsRef.current?.send(JSON.stringify({ type: on ? 'typing.start' : 'typing.stop', data: {} }));
}
return { messages, typing, state, send, sendTyping };
}
Use it:
// your-app/apps/web/src/screens/ContactDetail.tsx
function ContactChatTab({ channelId }: { channelId: string }) {
const { messages, typing, send } = useChat(channelId);
const [draft, setDraft] = useState('');
return (
<div>
<div className="messages">
{messages.map((m) => (
<div key={m.id}><b>{m.authorName}:</b> {m.body}</div>
))}
{typing.length > 0 && <div className="muted">{typing.join(', ')} typing…</div>}
</div>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && draft) { send(draft); setDraft(''); }}}
/>
</div>
);
}
useLiveStream(streamKey) hook
Just watches an HLS stream. For publishing, see Live stream →.
// your-app/apps/web/src/kardolive/useLiveStream.ts
import { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';
export function useLiveStream(streamKey: string) {
const videoRef = useRef<HTMLVideoElement>(null);
const [state, setState] = useState<'loading' | 'playing' | 'offline' | 'error'>('loading');
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const url = `https://kardocloud.com/live/${streamKey}.m3u8`;
// Safari + iOS: native HLS
if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = url;
v.addEventListener('playing', () => setState('playing'));
v.addEventListener('error', () => setState('offline'));
v.play().catch(() => {});
return;
}
// Other browsers: hls.js
if (!Hls.isSupported()) { setState('error'); return; }
const hls = new Hls({
lowLatencyMode: true, manifestLoadingMaxRetry: 30, manifestLoadingRetryDelay: 1500,
});
hls.loadSource(url);
hls.attachMedia(v);
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}));
hls.on(Hls.Events.ERROR, (_e, data) => {
if (data.fatal) setState(data.type === 'networkError' ? 'offline' : 'error');
});
v.addEventListener('playing', () => setState('playing'));
return () => hls.destroy();
}, [streamKey]);
return { videoRef, state };
}
Use:
function StreamViewer({ streamKey }: { streamKey: string }) {
const { videoRef, state } = useLiveStream(streamKey);
return (
<div>
<video ref={videoRef} controls muted playsInline style={{ width: '100%' }} />
<div>Status: {state}</div>
</div>
);
}
useSession(sessionId) hook
The big one — handles conferences, calls, interactive livestreams. Uses
mediasoup-client under the hood.
// your-app/apps/web/src/kardolive/useSession.ts
import { useEffect, useRef, useState } from 'react';
import { Device } from 'mediasoup-client';
import { useKardolive } from './KardoliveProvider';
type Remote = { peerId: string; stream: MediaStream };
export function useSession(sessionId: string, opts: { audio?: boolean; video?: boolean } = {}) {
const { mintSessionToken } = useKardolive();
const localRef = useRef<HTMLVideoElement>(null);
const [remotes, setRemotes] = useState<Remote[]>([]);
const [state, setState] = useState<'connecting' | 'connected' | 'reconnecting' | 'closed'>('connecting');
const sessionRef = useRef<any>(null);
useEffect(() => {
let cancelled = false;
async function connect() {
const { token, sfuUrl } = await mintSessionToken(sessionId);
if (cancelled) return;
const ws = new WebSocket(`${sfuUrl}/?token=${token}&room=${sessionId}`);
const device = new Device();
const pending = new Map();
let nextId = 1;
const rpc = (method: string, params: any = {}) => new Promise<any>((resolve, reject) => {
const id = String(nextId++);
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, type: 'request', method, params }));
setTimeout(() => pending.has(id) && reject(new Error('timeout')), 15000);
});
await new Promise((r) => (ws.onopen = r));
setState('connected');
ws.onmessage = async (e) => {
const m = JSON.parse(e.data);
if (m.type === 'response') {
const { resolve, reject } = pending.get(m.id) || {};
pending.delete(m.id);
m.error ? reject?.(new Error(m.error.msg)) : resolve?.(m.result);
} else if (m.type === 'notify' && m.method === 'new-producer') {
const r = await rpc('consume', { producerId: m.params.producerId, rtpCapabilities: device.rtpCapabilities });
const consumer = await recvT.consume({ id: r.id, producerId: r.producerId, kind: r.kind, rtpParameters: r.rtpParameters });
await rpc('resumeConsumer', { consumerId: consumer.id });
setRemotes((prev) => {
const existing = prev.find((p) => p.peerId === m.params.peerId);
if (existing) { existing.stream.addTrack(consumer.track); return [...prev]; }
const stream = new MediaStream([consumer.track]);
return [...prev, { peerId: m.params.peerId, stream }];
});
} else if (m.type === 'notify' && m.method === 'peer-left') {
setRemotes((prev) => prev.filter((p) => p.peerId !== m.params.peerId));
}
};
ws.onclose = () => setState('closed');
const { rtpCapabilities } = await rpc('getRouterRtpCapabilities');
await device.load({ routerRtpCapabilities: rtpCapabilities });
await rpc('join', { rtpCapabilities: device.rtpCapabilities });
const sendT = device.createSendTransport(await rpc('createWebRtcTransport', { direction: 'send' }));
sendT.on('connect', ({ dtlsParameters }, cb, errb) => rpc('connectTransport', { transportId: sendT.id, dtlsParameters }).then(cb, errb));
sendT.on('produce', async ({ kind, rtpParameters }, cb, errb) => {
try { const { producerId } = await rpc('produce', { transportId: sendT.id, kind, rtpParameters }); cb({ id: producerId }); }
catch (e: any) { errb(e); }
});
const recvT = device.createRecvTransport(await rpc('createWebRtcTransport', { direction: 'recv' }));
recvT.on('connect', ({ dtlsParameters }, cb, errb) => rpc('connectTransport', { transportId: recvT.id, dtlsParameters }).then(cb, errb));
const local = await navigator.mediaDevices.getUserMedia({
audio: opts.audio !== false,
video: opts.video !== false ? { width: 1280, height: 720 } : false,
});
if (localRef.current) localRef.current.srcObject = local;
for (const t of local.getTracks()) await sendT.produce({ track: t });
sessionRef.current = { ws, sendT, recvT, local };
}
connect().catch((e) => { console.error(e); setState('closed'); });
return () => {
cancelled = true;
const s = sessionRef.current;
s?.local?.getTracks().forEach((t: MediaStreamTrack) => t.stop());
s?.ws?.close();
};
}, [sessionId]);
return { localRef, remotes, state };
}
Use:
function ConferenceView({ sessionId }: { sessionId: string }) {
const { localRef, remotes, state } = useSession(sessionId);
return (
<div className="grid">
<video ref={localRef} autoPlay muted playsInline />
{remotes.map((r) => (
<video key={r.peerId} autoPlay playsInline ref={(el) => el && (el.srcObject = r.stream)} />
))}
<div>{state}</div>
</div>
);
}
Full your app contact-screen example
Imagine the user opens a contact in your app. They want chat + a call button.
// your-app/apps/web/src/screens/ContactScreen.tsx
import { useEffect, useState } from 'react';
import { useChat } from '../kardolive/useChat';
import { useKardolive } from '../kardolive/KardoliveProvider';
export function ContactScreen({ contactId }: { contactId: string }) {
const [channelId, setChannelId] = useState<string | null>(null);
const [callSessionId, setCallSessionId] = useState<string | null>(null);
// On mount, ensure a DM channel exists between current user + contact.
useEffect(() => {
fetch('/api/kardolive/ensure-dm', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contactId }),
}).then(r => r.json()).then(c => setChannelId(c.id));
}, [contactId]);
async function startCall(kind: 'voice' | 'video') {
const r = await fetch('/api/kardolive/calls/invite', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contactId, kind }),
});
const { sessionId } = await r.json();
setCallSessionId(sessionId);
}
return (
<div>
<header>
<h2>Contact #{contactId}</h2>
<button onClick={() => startCall('voice')}>📞</button>
<button onClick={() => startCall('video')}>📹</button>
</header>
{channelId && <ChatTab channelId={channelId} />}
{callSessionId && <CallOverlay sessionId={callSessionId} onEnd={() => setCallSessionId(null)} />}
</div>
);
}
Each piece (ensure-dm, calls/invite) is a thin your app backend endpoint that proxies to Kardolive with the API key. See
Chat and Calls for those.