@smarthivelabs-devs/hive-socket-expo
v1.1.0
Published
Expo/React Native SDK for Hive-Socket — real-time hooks and provider for mobile apps
Readme
@smarthivelabs-devs/hive-socket-expo
Expo / React Native SDK for Hive-Socket — real-time hooks and provider for mobile apps.
Same API as @smarthivelabs-devs/hive-socket-react. The only difference is transport: React Native does not support HTTP polling, so this package uses WebSocket-only, which works natively in Expo and bare React Native.
Install
npx expo install @smarthivelabs-devs/hive-socket-expo socket.io-clientFor bare React Native:
npm install @smarthivelabs-devs/hive-socket-expo socket.io-clientPeer dependencies: react >= 18, react-native >= 0.73, socket.io-client ^4
Setup
1. Wrap your root layout with the provider
// app/_layout.tsx (Expo Router)
import { HiveSocketProvider } from '@smarthivelabs-devs/hive-socket-expo';
import { useAuth } from '@smarthivelabs-devs/auth-expo'; // your SmartHive Auth hook
import { Slot } from 'expo-router';
export default function RootLayout() {
const { accessToken } = useAuth();
return (
<HiveSocketProvider
url="https://socket.smarthivelabs.dev"
token={accessToken ?? ''}
>
<Slot />
</HiveSocketProvider>
);
}The provider reconnects automatically when the token changes (e.g. after a token refresh).
Without Expo Router:
// App.tsx
import { HiveSocketProvider } from '@smarthivelabs-devs/hive-socket-expo';
export default function App() {
const { accessToken } = useAuth();
return (
<HiveSocketProvider url="https://socket.smarthivelabs.dev" token={accessToken ?? ''}>
<NavigationContainer>
{/* your app */}
</NavigationContainer>
</HiveSocketProvider>
);
}2. Use hooks in any screen or component
import {
useHiveSocket,
useMessages,
usePresence,
useNotifications,
} from '@smarthivelabs-devs/hive-socket-expo';Hooks
useHiveSocket
Connection status and all send actions.
const {
status, // 'connecting' | 'connected' | 'disconnected'
socketId, // string | null
joinRoom, // (roomId: string) => void
leaveRoom, // (roomId: string) => void
sendMessage, // (roomId: string, type: string, payload: Record<string, unknown>) => void
updatePresence, // (status: 'online' | 'away' | 'offline') => void
fetchHistory, // (roomId: string, limit?: number, before?: string) => void
markNotificationRead, // (notificationId: string) => void
} = useHiveSocket();Connection status bar:
import { useHiveSocket } from '@smarthivelabs-devs/hive-socket-expo';
import { View, Text } from 'react-native';
function StatusBar() {
const { status } = useHiveSocket();
if (status === 'connected') return null;
return (
<View style={{ backgroundColor: '#f59e0b', padding: 8, alignItems: 'center' }}>
<Text style={{ color: 'white', fontSize: 12 }}>
{status === 'connecting' ? 'Connecting...' : 'Offline — reconnecting'}
</Text>
</View>
);
}useMessages(roomId)
Subscribe to real-time messages in a room. Automatically joins on mount, loads the last 50 messages, and leaves on unmount.
const messages = useMessages(roomId); // HiveMessage[]import { useMessages, useHiveSocket } from '@smarthivelabs-devs/hive-socket-expo';
import { FlatList, TextInput, TouchableOpacity, View, Text, StyleSheet } from 'react-native';
import { useState } from 'react';
export default function ChatScreen({ roomId }: { roomId: string }) {
const { status, sendMessage } = useHiveSocket();
const messages = useMessages(roomId);
const [text, setText] = useState('');
const send = () => {
if (!text.trim() || status !== 'connected') return;
sendMessage(roomId, 'chat.message', { text });
setText('');
};
return (
<View style={styles.container}>
<FlatList
data={[...messages].reverse()}
keyExtractor={(m) => m._eid}
inverted
renderItem={({ item }) => (
<View style={styles.message}>
<Text style={styles.sender}>{item.senderId}</Text>
<Text>{item.payload.text as string}</Text>
</View>
)}
/>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={text}
onChangeText={setText}
placeholder="Type a message..."
returnKeyType="send"
onSubmitEditing={send}
/>
<TouchableOpacity
style={[styles.sendBtn, status !== 'connected' && styles.sendBtnDisabled]}
onPress={send}
disabled={status !== 'connected'}
>
<Text style={{ color: 'white' }}>Send</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
message: { padding: 12, borderBottomWidth: 1, borderBottomColor: '#f0f0f0' },
sender: { fontWeight: 'bold', marginBottom: 4 },
inputRow: { flexDirection: 'row', padding: 12, borderTopWidth: 1, borderTopColor: '#eee' },
input: { flex: 1, borderWidth: 1, borderColor: '#ddd', borderRadius: 8, paddingHorizontal: 12, marginRight: 8, height: 40 },
sendBtn: { backgroundColor: '#2563eb', borderRadius: 8, paddingHorizontal: 16, justifyContent: 'center' },
sendBtnDisabled: { backgroundColor: '#93c5fd' },
});HiveMessage shape:
interface HiveMessage {
type: string; // e.g. 'chat.message', 'quiz.answer'
senderId: string; // userId of the sender
roomId: string;
payload: Record<string, unknown>; // your custom data
timestamp: number; // Unix ms
_eid: string; // unique event ID — use as FlatList key
}usePresence(userId)
Track another user's live presence status.
const presence = usePresence(userId);
// { status: 'online' | 'away' | 'offline', lastSeen: string } | nullimport { usePresence } from '@smarthivelabs-devs/hive-socket-expo';
import { View, Image } from 'react-native';
export function UserAvatar({ userId, avatarUri }: { userId: string; avatarUri: string }) {
const presence = usePresence(userId);
return (
<View style={{ position: 'relative' }}>
<Image source={{ uri: avatarUri }} style={{ width: 40, height: 40, borderRadius: 20 }} />
<View
style={{
position: 'absolute',
bottom: 0,
right: 0,
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: presence?.status === 'online' ? '#22c55e' : '#9ca3af',
borderWidth: 2,
borderColor: 'white',
}}
/>
</View>
);
}Sync presence with app state (background / foreground):
import { useHiveSocket } from '@smarthivelabs-devs/hive-socket-expo';
import { AppState, AppStateStatus } from 'react-native';
import { useEffect } from 'react';
export function usePresenceSync() {
const { updatePresence } = useHiveSocket();
useEffect(() => {
const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
updatePresence(state === 'active' ? 'online' : 'away');
});
return () => sub.remove();
}, [updatePresence]);
}Call usePresenceSync() once in your root layout to automatically mark the user as away when they background the app.
useNotifications
Receive real-time notifications pushed from your backends. Pending (unread) notifications load automatically on connect.
const {
notifications, // HiveNotification[]
unreadCount, // number
markRead, // (notificationId: string) => void
} = useNotifications();Notification badge on tab bar:
import { useNotifications } from '@smarthivelabs-devs/hive-socket-expo';
import { View, Text } from 'react-native';
export function NotificationBadge() {
const { unreadCount } = useNotifications();
if (unreadCount === 0) return null;
return (
<View
style={{
position: 'absolute',
top: -4,
right: -4,
backgroundColor: '#ef4444',
borderRadius: 10,
minWidth: 18,
height: 18,
paddingHorizontal: 4,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ color: 'white', fontSize: 10, fontWeight: 'bold' }}>
{unreadCount > 99 ? '99+' : unreadCount}
</Text>
</View>
);
}Notification list screen:
import { useNotifications } from '@smarthivelabs-devs/hive-socket-expo';
import { FlatList, View, Text, TouchableOpacity, StyleSheet } from 'react-native';
export default function NotificationsScreen() {
const { notifications, markRead } = useNotifications();
return (
<FlatList
data={notifications}
keyExtractor={(n) => n.id}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.item, !item.read && styles.unread]}
onPress={() => !item.read && markRead(item.id)}
>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.body}>{item.body}</Text>
<Text style={styles.time}>
{new Date(item.createdAt).toLocaleString()}
</Text>
</TouchableOpacity>
)}
ListEmptyComponent={<Text style={{ textAlign: 'center', marginTop: 40, color: '#9ca3af' }}>No notifications</Text>}
/>
);
}
const styles = StyleSheet.create({
item: { padding: 16, borderBottomWidth: 1, borderBottomColor: '#f0f0f0', backgroundColor: '#fff' },
unread: { backgroundColor: '#eff6ff' },
title: { fontWeight: 'bold', fontSize: 15, marginBottom: 4 },
body: { color: '#374151', marginBottom: 4 },
time: { color: '#9ca3af', fontSize: 12 },
});HiveNotification shape:
interface HiveNotification {
id: string;
title: string;
body: string;
type: 'info' | 'success' | 'warning' | 'error' | 'message';
metadata: Record<string, unknown>;
sourceService?: string; // which backend sent it
read: boolean;
readAt: string | null; // ISO date string
createdAt: string; // ISO date string
}useWebRTC
Peer-to-peer audio, video, screen sharing, and data channels over WebRTC. The existing Socket.IO connection handles signaling — media streams flow directly between peers. Requires react-native-webrtc for actual media on React Native (see note below).
const {
callState, // 'idle' | 'calling' | 'incoming' | 'connecting' | 'active' | 'ended'
currentCall, // HiveWebRtcCall | null
localStream, // MediaStream | null
remoteStream, // MediaStream | null
screenStream, // MediaStream | null
dataChannel, // RTCDataChannel | null
isAudioMuted, // boolean
isVideoOff, // boolean
isSharingScreen, // boolean
startCall, // (targetUserId: string, callType: WebRtcCallType) => Promise<void>
acceptCall, // () => Promise<void>
rejectCall, // () => void
endCall, // () => void
toggleAudio, // () => void
toggleVideo, // () => void
startScreenShare, // () => Promise<void>
stopScreenShare, // () => void
sendData, // (data: string | ArrayBuffer) => void
} = useWebRTC();WebRtcCallType: 'audio' | 'video' | 'screen' | 'data'
React Native / Expo setup:
React Native does not have a built-in RTCPeerConnection. Install and register react-native-webrtc first:
npx expo install react-native-webrtcThen register the globals before any WebRTC code runs (e.g. in your root _layout.tsx):
import { registerGlobals } from 'react-native-webrtc';
registerGlobals(); // makes RTCPeerConnection, MediaStream, etc. available globallyVideo call example (React Native):
import { useWebRTC } from '@smarthivelabs-devs/hive-socket-expo';
import { RTCView } from 'react-native-webrtc';
import { View, TouchableOpacity, Text, StyleSheet } from 'react-native';
function VideoCallScreen({ targetUserId }: { targetUserId: string }) {
const {
callState,
localStream,
remoteStream,
startCall,
acceptCall,
rejectCall,
endCall,
toggleAudio,
toggleVideo,
isAudioMuted,
isVideoOff,
} = useWebRTC();
if (callState === 'incoming') {
return (
<View style={styles.center}>
<Text style={styles.label}>Incoming call...</Text>
<TouchableOpacity style={styles.acceptBtn} onPress={acceptCall}>
<Text style={{ color: 'white' }}>Accept</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.rejectBtn} onPress={rejectCall}>
<Text style={{ color: 'white' }}>Decline</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={styles.container}>
{remoteStream && (
<RTCView streamURL={remoteStream.toURL()} style={styles.remoteVideo} objectFit="cover" />
)}
{localStream && (
<RTCView streamURL={localStream.toURL()} style={styles.localVideo} objectFit="cover" mirror />
)}
{callState === 'idle' ? (
<TouchableOpacity style={styles.callBtn} onPress={() => startCall(targetUserId, 'video')}>
<Text style={{ color: 'white' }}>Start Video Call</Text>
</TouchableOpacity>
) : (
<View style={styles.controls}>
<TouchableOpacity style={styles.controlBtn} onPress={toggleAudio}>
<Text style={{ color: 'white' }}>{isAudioMuted ? 'Unmute' : 'Mute'}</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.controlBtn, styles.endBtn]} onPress={endCall}>
<Text style={{ color: 'white' }}>End</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.controlBtn} onPress={toggleVideo}>
<Text style={{ color: 'white' }}>{isVideoOff ? 'Camera On' : 'Camera Off'}</Text>
</TouchableOpacity>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#000' },
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
remoteVideo: { flex: 1 },
localVideo: { position: 'absolute', top: 16, right: 16, width: 100, height: 140, borderRadius: 8 },
controls: { position: 'absolute', bottom: 40, flexDirection: 'row', alignSelf: 'center', gap: 16 },
controlBtn: { backgroundColor: 'rgba(0,0,0,0.6)', borderRadius: 32, paddingVertical: 12, paddingHorizontal: 20 },
endBtn: { backgroundColor: '#ef4444' },
callBtn: { position: 'absolute', bottom: 40, alignSelf: 'center', backgroundColor: '#22c55e', borderRadius: 32, paddingVertical: 14, paddingHorizontal: 32 },
label: { fontSize: 18, marginBottom: 24 },
acceptBtn: { backgroundColor: '#22c55e', borderRadius: 32, paddingVertical: 14, paddingHorizontal: 32, marginBottom: 12 },
rejectBtn: { backgroundColor: '#ef4444', borderRadius: 32, paddingVertical: 14, paddingHorizontal: 32 },
});Audio-only call:
await startCall(targetUserId, 'audio');Data channel:
const { startCall, dataChannel, sendData } = useWebRTC();
await startCall(targetUserId, 'data');
useEffect(() => {
if (!dataChannel) return;
dataChannel.onopen = () => sendData('hello peer');
dataChannel.onmessage = (e) => console.log('received:', e.data);
}, [dataChannel]);ICE / STUN configuration:
The server emits ICE server config automatically on connect (webrtc:config). The hook picks it up — no manual STUN/TURN configuration needed. To add a TURN server, set WEBRTC_TURN_URLS, WEBRTC_TURN_USERNAME, and WEBRTC_TURN_CREDENTIAL on the Hive-Socket server.
App background and reconnect
The provider automatically handles reconnect and missed event replay. When the app returns to the foreground and the socket reconnects, it sends reconnect:sync to the server which replays any events missed in the last 5 minutes. No extra code needed.
// Optional: listen for reconnect manually
const { status } = useHiveSocket();
useEffect(() => {
if (status === 'connected') {
// Already re-joined rooms and replayed missed events automatically
console.log('Back online');
}
}, [status]);Complete example — Expo Router chat screen
// app/(tabs)/chat/[roomId].tsx
import { useMessages, useHiveSocket } from '@smarthivelabs-devs/hive-socket-expo';
import { useLocalSearchParams } from 'expo-router';
import { FlatList, KeyboardAvoidingView, Platform, TextInput, TouchableOpacity, View, Text } from 'react-native';
import { useState } from 'react';
export default function ChatScreen() {
const { roomId } = useLocalSearchParams<{ roomId: string }>();
const { status, sendMessage } = useHiveSocket();
const messages = useMessages(roomId);
const [text, setText] = useState('');
const send = () => {
if (!text.trim() || status !== 'connected') return;
sendMessage(roomId, 'chat.message', { text });
setText('');
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<FlatList
data={[...messages].reverse()}
keyExtractor={(m) => m._eid}
inverted
contentContainerStyle={{ padding: 16 }}
renderItem={({ item }) => (
<View style={{ marginBottom: 8 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 2 }}>{item.senderId}</Text>
<Text>{item.payload.text as string}</Text>
</View>
)}
/>
<View style={{ flexDirection: 'row', padding: 12, borderTopWidth: 1, borderTopColor: '#eee' }}>
<TextInput
style={{ flex: 1, borderWidth: 1, borderColor: '#ddd', borderRadius: 20, paddingHorizontal: 16, paddingVertical: 8, marginRight: 8 }}
value={text}
onChangeText={setText}
placeholder="Message..."
multiline
/>
<TouchableOpacity
onPress={send}
disabled={status !== 'connected'}
style={{ backgroundColor: '#2563eb', borderRadius: 20, paddingHorizontal: 20, justifyContent: 'center', opacity: status !== 'connected' ? 0.5 : 1 }}
>
<Text style={{ color: 'white', fontWeight: '600' }}>Send</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
);
}Room naming
| Room pattern | Use case |
|-------------|----------|
| project:{id}:general | Project-wide chat |
| project:{id}:thread:{threadId} | Specific discussion thread |
| course:{courseId} | Course-level broadcast |
| event:{eventId} | Live voting session |
Each user is automatically in user:{userId} on connect — backend notifications always arrive without manual joinRoom.
Types
import type {
HiveMessage,
HiveHistoryMessage,
HiveNotification,
HivePresenceChange,
HiveSocketError,
HiveSocketProviderProps,
NotificationType,
PresenceStatus,
ConnectionStatus,
UseHiveSocketReturn,
PresenceState,
UseNotificationsReturn,
// WebRTC
WebRtcCallType,
WebRtcCallState,
HiveWebRtcCall,
HiveIceServer,
HiveWebRtcConfig,
UseWebRTCReturn,
} from '@smarthivelabs-devs/hive-socket-expo';| Type | Description |
|------|-------------|
| HiveMessage | Real-time message from a room |
| HiveHistoryMessage | Message row from history fetch |
| HiveNotification | User notification (real-time or pending) |
| HivePresenceChange | Presence status change event |
| HiveSocketError | Server error payload |
| ConnectionStatus | 'connecting' \| 'connected' \| 'disconnected' |
| PresenceStatus | 'online' \| 'away' \| 'offline' |
| NotificationType | 'info' \| 'success' \| 'warning' \| 'error' \| 'message' |
| WebRtcCallType | 'audio' \| 'video' \| 'screen' \| 'data' |
| WebRtcCallState | 'idle' \| 'calling' \| 'incoming' \| 'connecting' \| 'active' \| 'ended' |
| HiveWebRtcCall | Active call metadata (callId, callerId, targetId, callType) |
| HiveIceServer | ICE server config (urls, optional username/credential) |
| HiveWebRtcConfig | ICE config emitted by server on connect |
Security
How the token is sent and validated
When you pass token to HiveSocketProvider, the SDK sends it in the Socket.IO handshake auth object:
io(url, { auth: { token }, transports: ['websocket'] })The server validates the JWT during the WebSocket handshake — before any events are processed. If the token is invalid or expired the connection is rejected with UNAUTHORIZED immediately.
After verification the server extracts userId and projectId directly from the JWT claims. You never send these yourself — they cannot be spoofed by the client.
What the server enforces
| Guarantee | How |
|-----------|-----|
| Token is valid and unexpired | JWT signature verified on every new connection |
| User only receives their own notifications | Server auto-joins {projectId}::user:{userId} from the token — not from client input |
| User can only join rooms in their project | Room joins are namespaced to the token's projectId server-side |
| Stale tokens are rejected on reconnect | Every reconnect (foreground resume) re-runs full handshake verification |
What you must do
- Pass the access token, not the refresh token. Access tokens are short-lived; the server rejects expired ones and disconnects cleanly.
- Reconnect after a token refresh. The provider reconnects automatically every time
tokenchanges — SmartHive Auth's silent refresh triggers this with no extra code. - Use
token={accessToken ?? ''}at the root layout. The provider skips connecting when the token is falsy — safe to render before auth is ready. - Sync presence on foreground/background using
AppState(seeusePresenceSyncabove) so the server knows when the user is active.
Token lifecycle example (Expo Router)
// app/_layout.tsx
import { HiveSocketProvider } from '@smarthivelabs-devs/hive-socket-expo';
import { useAuth } from '@smarthivelabs-devs/auth-expo'; // SmartHive Auth hook
import { Slot } from 'expo-router';
export default function RootLayout() {
const { accessToken } = useAuth(); // null before auth resolves, auto-refreshed
// Provider skips connecting when token is falsy — safe to render unconditionally
return (
<HiveSocketProvider url="https://socket.smarthivelabs.dev" token={accessToken ?? ''}>
<Slot />
</HiveSocketProvider>
);
}When SmartHive Auth silently refreshes the access token, accessToken changes → provider tears down the old socket and reconnects with the new token automatically.
Troubleshooting
Connection fails — ERR_CONNECTION_REFUSED or immediate disconnect
- Ensure the Hive-Socket server allows WebSocket connections on port 443
- The Expo SDK uses
transports: ['websocket']only — if the server requires polling for the handshake, check that Nginx has the WebSocket upgrade headers configured
Token is empty string on mount
If your auth hook returns null before the token is ready, pass ''. The provider skips connecting when the token is falsy and connects automatically once it becomes a non-empty string.
<HiveSocketProvider url={url} token={accessToken ?? ''}>Messages not loading
useMessages waits for status === 'connected' before joining and fetching. If the connection never reaches 'connected', check that the token is valid and the server URL is reachable from the device.
Metro bundler errors with socket.io-client
Add a resolver alias if needed:
// metro.config.js
const config = {
resolver: {
extraNodeModules: {
'socket.io-client': require.resolve('socket.io-client'),
},
},
};Android: connection works on emulator but not on device
If testing on a real device with a local server (http://192.168.x.x), ensure the device is on the same network and the port is open. In production, always use HTTPS.
