mirror of
https://github.com/xtr-dev/rondevu-demo.git
synced 2025-12-10 02:43:23 +00:00
feat: v0.9.0 - durable WebRTC connections with automatic reconnection
- Replace low-level APIs with high-level durable connections
- Add automatic reconnection with exponential backoff
- Add message queuing during disconnections
- Add TTL auto-refresh for services
- Add comprehensive TypeScript types
- Update README and create MIGRATION.md guide
BREAKING CHANGES:
- Removed: client.services.*, client.discovery.*, client.createPeer()
- Added: client.exposeService(), client.connect(), client.connectByUuid()
- Handler signature changed from (channel, peer, connectionId?) to (channel, connectionId)
- Channels now use .on('message') instead of .onmessage
- Services must call service.start() to begin accepting connections
This commit is contained in:
134
src/App.jsx
134
src/App.jsx
@@ -124,6 +124,8 @@ export default function App() {
|
|||||||
}, [setupStep, myUsername, client]);
|
}, [setupStep, myUsername, client]);
|
||||||
|
|
||||||
// Check online status periodically
|
// Check online status periodically
|
||||||
|
// Note: Online detection by attempting to query services
|
||||||
|
// In v0.9.0 there's no direct listServices API, so we check by attempting connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (setupStep !== 'ready' || !client) return;
|
if (setupStep !== 'ready' || !client) return;
|
||||||
|
|
||||||
@@ -131,8 +133,14 @@ export default function App() {
|
|||||||
const online = new Set();
|
const online = new Set();
|
||||||
for (const contact of contacts) {
|
for (const contact of contacts) {
|
||||||
try {
|
try {
|
||||||
const services = await client.discovery.listServices(contact);
|
// Try to query the service via discovery endpoint
|
||||||
if (services.services.some(s => s.serviceFqn === 'chat.rondevu@1.0.0')) {
|
const response = await fetch(`${API_URL}/index/${contact}/query`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ serviceFqn: 'chat.rondevu@1.0.0' })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
online.add(contact);
|
online.add(contact);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -163,7 +171,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start pooled chat service
|
// Start pooled chat service with durable connections
|
||||||
const startChatService = async () => {
|
const startChatService = async () => {
|
||||||
if (!client || !myUsername || serviceHandle) return;
|
if (!client || !myUsername || serviceHandle) return;
|
||||||
|
|
||||||
@@ -174,21 +182,22 @@ export default function App() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handle = await client.services.exposeService({
|
const service = await client.exposeService({
|
||||||
username: myUsername,
|
username: myUsername,
|
||||||
privateKey: keypair.privateKey,
|
privateKey: keypair.privateKey,
|
||||||
serviceFqn: 'chat.rondevu@1.0.0',
|
serviceFqn: 'chat.rondevu@1.0.0',
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
ttl: 300000, // 5 minutes - service stays alive while page is open
|
ttl: 300000, // 5 minutes - service auto-refreshes
|
||||||
|
ttlRefreshMargin: 0.2, // Refresh at 80% of TTL
|
||||||
poolSize: 10, // Support up to 10 simultaneous connections
|
poolSize: 10, // Support up to 10 simultaneous connections
|
||||||
rtcConfig: RTC_CONFIG,
|
rtcConfig: RTC_CONFIG,
|
||||||
handler: (channel, peer, connectionId) => {
|
handler: (channel, connectionId) => {
|
||||||
console.log(`📡 New chat connection: ${connectionId}`);
|
console.log(`📡 New chat connection: ${connectionId}`);
|
||||||
|
|
||||||
// Wait for peer to identify themselves
|
// Wait for peer to identify themselves
|
||||||
channel.onmessage = (event) => {
|
channel.on('message', (data) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(data);
|
||||||
|
|
||||||
if (msg.type === 'identify') {
|
if (msg.type === 'identify') {
|
||||||
// Peer identified themselves
|
// Peer identified themselves
|
||||||
@@ -203,10 +212,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Update message handler for actual chat messages
|
// Remove old handler and add new one for chat messages
|
||||||
channel.onmessage = (e) => {
|
channel.off('message');
|
||||||
|
channel.on('message', (chatData) => {
|
||||||
try {
|
try {
|
||||||
const chatMsg = JSON.parse(e.data);
|
const chatMsg = JSON.parse(chatData);
|
||||||
if (chatMsg.type === 'message') {
|
if (chatMsg.type === 'message') {
|
||||||
setActiveChats(prev => ({
|
setActiveChats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -223,7 +233,7 @@ export default function App() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse chat message:', err);
|
console.error('Failed to parse chat message:', err);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
// Send acknowledgment
|
// Send acknowledgment
|
||||||
channel.send(JSON.stringify({
|
channel.send(JSON.stringify({
|
||||||
@@ -234,9 +244,9 @@ export default function App() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse identify message:', err);
|
console.error('Failed to parse identify message:', err);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
channel.onclose = () => {
|
channel.on('close', () => {
|
||||||
console.log(`👋 Chat closed: ${connectionId}`);
|
console.log(`👋 Chat closed: ${connectionId}`);
|
||||||
setActiveChats(prev => {
|
setActiveChats(prev => {
|
||||||
const updated = { ...prev };
|
const updated = { ...prev };
|
||||||
@@ -247,14 +257,31 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
},
|
|
||||||
onError: (error, context) => {
|
|
||||||
console.error(`Chat service error (${context}):`, error);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setServiceHandle(handle);
|
// Start the service
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
// Listen for service events
|
||||||
|
service.on('connection', (connId) => {
|
||||||
|
console.log(`🔗 New connection: ${connId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
service.on('disconnection', (connId) => {
|
||||||
|
console.log(`🔌 Disconnected: ${connId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
service.on('ttl-refreshed', (expiresAt) => {
|
||||||
|
console.log(`🔄 Service TTL refreshed, expires at: ${new Date(expiresAt)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
service.on('error', (error, context) => {
|
||||||
|
console.error(`❌ Service error (${context}):`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
setServiceHandle(service);
|
||||||
console.log('✅ Chat service started');
|
console.log('✅ Chat service started');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error starting chat service:', err);
|
console.error('Error starting chat service:', err);
|
||||||
@@ -291,7 +318,7 @@ export default function App() {
|
|||||||
toast.success(`Removed ${contact}`);
|
toast.success(`Removed ${contact}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start chat with contact
|
// Start chat with contact using durable connection
|
||||||
const handleStartChat = async (contact) => {
|
const handleStartChat = async (contact) => {
|
||||||
if (!client || activeChats[contact]?.status === 'connected') {
|
if (!client || activeChats[contact]?.status === 'connected') {
|
||||||
setSelectedChat(contact);
|
setSelectedChat(contact);
|
||||||
@@ -301,18 +328,46 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
toast.loading(`Connecting to ${contact}...`, { id: 'connecting' });
|
toast.loading(`Connecting to ${contact}...`, { id: 'connecting' });
|
||||||
|
|
||||||
const { peer, channel } = await client.discovery.connect(contact, 'chat.rondevu@1.0.0', { rtcConfig: RTC_CONFIG });
|
// Create durable connection
|
||||||
|
const connection = await client.connect(contact, 'chat.rondevu@1.0.0', {
|
||||||
|
rtcConfig: RTC_CONFIG,
|
||||||
|
maxReconnectAttempts: 5
|
||||||
|
});
|
||||||
|
|
||||||
// Send identification
|
// Create data channel
|
||||||
channel.send(JSON.stringify({
|
const channel = connection.createChannel('chat');
|
||||||
type: 'identify',
|
|
||||||
from: myUsername
|
// Listen for connection events
|
||||||
}));
|
connection.on('connected', () => {
|
||||||
|
console.log(`✅ Connected to ${contact}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('reconnecting', (attempt, max, delay) => {
|
||||||
|
console.log(`🔄 Reconnecting to ${contact} (${attempt}/${max}) in ${delay}ms`);
|
||||||
|
toast.loading(`Reconnecting to ${contact}...`, { id: 'reconnecting' });
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('disconnected', () => {
|
||||||
|
console.log(`🔌 Disconnected from ${contact}`);
|
||||||
|
setActiveChats(prev => ({
|
||||||
|
...prev,
|
||||||
|
[contact]: { ...prev[contact], status: 'reconnecting' }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('failed', (error) => {
|
||||||
|
console.error(`❌ Connection to ${contact} failed:`, error);
|
||||||
|
toast.error(`Connection to ${contact} failed`, { id: 'connecting' });
|
||||||
|
setActiveChats(prev => ({
|
||||||
|
...prev,
|
||||||
|
[contact]: { ...prev[contact], status: 'disconnected' }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for acknowledgment
|
// Wait for acknowledgment
|
||||||
channel.onmessage = (event) => {
|
channel.on('message', (data) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data);
|
const msg = JSON.parse(data);
|
||||||
|
|
||||||
if (msg.type === 'identify_ack') {
|
if (msg.type === 'identify_ack') {
|
||||||
// Connection established
|
// Connection established
|
||||||
@@ -323,7 +378,7 @@ export default function App() {
|
|||||||
[contact]: {
|
[contact]: {
|
||||||
username: contact,
|
username: contact,
|
||||||
channel,
|
channel,
|
||||||
peer,
|
connection,
|
||||||
messages: prev[contact]?.messages || [],
|
messages: prev[contact]?.messages || [],
|
||||||
status: 'connected'
|
status: 'connected'
|
||||||
}
|
}
|
||||||
@@ -331,9 +386,10 @@ export default function App() {
|
|||||||
setSelectedChat(contact);
|
setSelectedChat(contact);
|
||||||
|
|
||||||
// Update handler for chat messages
|
// Update handler for chat messages
|
||||||
channel.onmessage = (e) => {
|
channel.off('message');
|
||||||
|
channel.on('message', (chatData) => {
|
||||||
try {
|
try {
|
||||||
const chatMsg = JSON.parse(e.data);
|
const chatMsg = JSON.parse(chatData);
|
||||||
if (chatMsg.type === 'message') {
|
if (chatMsg.type === 'message') {
|
||||||
setActiveChats(prev => ({
|
setActiveChats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -350,20 +406,28 @@ export default function App() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse message:', err);
|
console.error('Failed to parse message:', err);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse ack:', err);
|
console.error('Failed to parse ack:', err);
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
channel.onclose = () => {
|
channel.on('close', () => {
|
||||||
setActiveChats(prev => ({
|
setActiveChats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[contact]: { ...prev[contact], status: 'disconnected' }
|
[contact]: { ...prev[contact], status: 'disconnected' }
|
||||||
}));
|
}));
|
||||||
toast.error(`Disconnected from ${contact}`);
|
toast.error(`Disconnected from ${contact}`);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
// Connect and send identification
|
||||||
|
await connection.connect();
|
||||||
|
|
||||||
|
channel.send(JSON.stringify({
|
||||||
|
type: 'identify',
|
||||||
|
from: myUsername
|
||||||
|
}));
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to connect:', err);
|
console.error('Failed to connect:', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user