Compare commits

..

5 Commits

Author SHA1 Message Date
3a42f74371 Add TURNS (secure) endpoints for upgraded TURN server
Updated ICE configuration to use TURNS (TLS/DTLS) on port 5349
as the preferred relay method, with plain TURN on port 3478 as
fallback. WebRTC will try secure endpoints first for better
security and reliability.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 15:58:15 +01:00
2cbd46b27a Fix datachannel name to match service pool
Changed channel name from 'chat' to 'rondevu-service' to match the
channel name created by the service pool. This fixes the connection
failure where the offerer's channel and answerer's channel had
different names and couldn't connect.

The service pool creates a channel named 'rondevu-service', so clients
must use the same name to receive that channel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 13:59:30 +01:00
b3dde85cd2 Force TURN relay mode to bypass NAT hairpinning
Added iceTransportPolicy: 'relay' to RTC_CONFIG to force TURN relay usage.
This bypasses NAT hairpinning issues when testing on the same network
(e.g., two browser tabs on the same machine).

This setting ensures maximum compatibility and reliability for WebRTC
connections by always using the TURN relay instead of attempting direct
or STUN-based connections.

Note: Should be commented out in production to allow direct connections
when possible for better performance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 13:49:52 +01:00
66dc35c1a7 Remove rondevu-client from dependencies (using npm link)
The demo now uses npm link to use the local client development version
instead of the published package from npm registry.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 13:30:06 +01:00
74bf2757ff 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
2025-12-06 12:58:30 +01:00
4 changed files with 134 additions and 61 deletions

View File

@@ -6,12 +6,37 @@
When configuring TURN servers: When configuring TURN servers:
-**DO** include the port number in TURN URLs: `turn:server.com:3478` -**DO** use TURNS (secure) on port 5349 when available: `turns:server.com:5349`
-**DO** include TURN fallback on port 3478: `turn:server.com:3478`
-**DO** include the port number in TURN URLs (even if default)
-**DO** test TURN connectivity before deploying: `turnutils_uclient -u user -w pass server.com 3478 -y` -**DO** test TURN connectivity before deploying: `turnutils_uclient -u user -w pass server.com 3478 -y`
-**DO** provide both TCP and UDP transports for maximum compatibility -**DO** provide both TCP and UDP transports for maximum compatibility
-**DON'T** omit the port number (even if it's the default 3478) -**DON'T** omit the port number
-**DON'T** assume TURN works without testing -**DON'T** assume TURN works without testing
**Current Configuration:**
```javascript
const RTC_CONFIG = {
iceServers: [
{ urls: ["stun:stun.share.fish:3478"] },
{
urls: [
// TURNS (secure) - TLS/DTLS on port 5349 (preferred)
"turns:turn.share.fish:5349?transport=tcp",
"turns:turn.share.fish:5349?transport=udp",
// TURN (fallback) - plain on port 3478
"turn:turn.share.fish:3478?transport=tcp",
"turn:turn.share.fish:3478?transport=udp",
],
username: "webrtcuser",
credential: "supersecretpassword"
}
]
};
```
WebRTC will try TURNS (secure) endpoints first, falling back to plain TURN if needed.
### ICE Configuration ### ICE Configuration
**Force Relay Mode for Testing:** **Force Relay Mode for Testing:**

19
package-lock.json generated
View File

@@ -8,7 +8,6 @@
"name": "rondevu-demo", "name": "rondevu-demo",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@xtr-dev/rondevu-client": "^0.8.3",
"@zxing/library": "^0.21.3", "@zxing/library": "^0.21.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.2.0", "react": "^18.2.0",
@@ -745,15 +744,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@noble/ed25519": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz",
"integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1170,15 +1160,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@xtr-dev/rondevu-client": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.8.3.tgz",
"integrity": "sha512-yvTZYQiOeIuaQgTZPhO3Wud9OEi7Mwxt9b4JYWlhwaReVUNajI1fpWTO8hHu8BrkiowVDabDN0qLFpwRufEvsw==",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0"
}
},
"node_modules/@zxing/library": { "node_modules/@zxing/library": {
"version": "0.21.3", "version": "0.21.3",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz", "resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.21.3.tgz",

View File

@@ -10,7 +10,6 @@
"deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo" "deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo"
}, },
"dependencies": { "dependencies": {
"@xtr-dev/rondevu-client": "^0.8.3",
"@zxing/library": "^0.21.3", "@zxing/library": "^0.21.3",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.2.0", "react": "^18.2.0",

View File

@@ -6,16 +6,20 @@ const API_URL = 'https://api.ronde.vu';
const RTC_CONFIG = { const RTC_CONFIG = {
iceServers: [ iceServers: [
{ urls: ["stun:stun.ronde.vu:3478"] }, { urls: ["stun:stun.share.fish:3478"] },
{ {
urls: [ urls: [
"turn:turn.ronde.vu:3478?transport=tcp", // TURNS (secure) - TLS/DTLS on port 5349
"turn:turn.ronde.vu:3478?transport=udp", "turns:turn.share.fish:5349?transport=tcp",
"turns:turn.share.fish:5349?transport=udp",
// TURN (fallback) - plain on port 3478
"turn:turn.share.fish:3478?transport=tcp",
"turn:turn.share.fish:3478?transport=udp",
], ],
username: "webrtcuser", username: "webrtcuser",
credential: "supersecretpassword" credential: "supersecretpassword"
} }
] ],
}; };
export default function App() { export default function App() {
@@ -124,6 +128,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 +137,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 +175,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 +186,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 +216,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 +237,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 +248,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 +261,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 +322,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 +332,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 (must match service pool's channel name)
channel.send(JSON.stringify({ const channel = connection.createChannel('rondevu-service');
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 +382,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 +390,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 +410,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);