v0.0.5: WebSocket real-time messaging

Server:
- WS endpoint: /v1/ws/:fingerprint
- Connection registry in AppState (fingerprint → WS senders)
- On connect: flushes queued DB messages, then pushes in real-time
- send_message: pushes to WS if connected, falls back to DB queue
- Auto-cleanup on disconnect
- WS accepts both binary and JSON text frames for sending

Web client:
- Replaces 2-second HTTP polling with persistent WebSocket
- Auto-reconnects on disconnect (3-second backoff)
- Sends via WS when connected, HTTP fallback
- Messages arrive instantly (no polling delay)
- "Real-time connection established" shown on connect

HTTP polling still works:
- CLI recv command uses HTTP (unchanged)
- Web falls back to HTTP if WS fails
- Mules/scripts can still use HTTP API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 09:41:50 +04:00
parent 6cf2a1814c
commit 2ca25fd2bf
8 changed files with 425 additions and 99 deletions

View File

@@ -157,9 +157,10 @@ let mySeedHex = '';
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
let peerBundles = {}; // peerFP -> bundle bytes
let pollTimer = null;
let ws = null; // WebSocket connection
let wasmReady = false;
const VERSION = '0.0.4';
const VERSION = '0.0.5';
let DEBUG = true; // toggle with /debug command
function dbg(...args) {
@@ -281,101 +282,122 @@ async function sendEncrypted(peerFP, plaintext) {
));
dbg('Sending wire message, size:', wireBytes.length);
await fetch(SERVER + '/v1/messages/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// Prefer WebSocket, fall back to HTTP
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
to: fp,
from: normFP(myFingerprint),
message: Array.from(wireBytes)
})
});
dbg('Message sent');
}));
dbg('Sent via WebSocket');
} else {
await fetch(SERVER + '/v1/messages/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: fp,
from: normFP(myFingerprint),
message: Array.from(wireBytes)
})
});
dbg('Sent via HTTP (WS not connected)');
}
}
async function pollMessages() {
if (!wasmReady) return;
function connectWebSocket() {
const fp = normFP(myFingerprint);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = proto + '//' + location.host + '/v1/ws/' + fp;
dbg('Connecting WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
dbg('WebSocket connected');
addSys('Real-time connection established');
};
ws.onmessage = async (event) => {
const bytes = new Uint8Array(event.data);
dbg('WS received', bytes.length, 'bytes');
await handleIncomingMessage(bytes);
};
ws.onclose = () => {
dbg('WebSocket closed, reconnecting in 3s...');
addSys('Connection lost, reconnecting...');
setTimeout(connectWebSocket, 3000);
};
ws.onerror = (e) => {
dbg('WebSocket error:', e);
};
}
async function handleIncomingMessage(bytes) {
dbg('Processing message,', bytes.length, 'bytes, sessions:', Object.keys(sessions));
// First try: KeyExchange (no existing session needed)
let decrypted = false;
try {
const fp = normFP(myFingerprint);
const resp = await fetch(SERVER + '/v1/messages/poll/' + fp);
if (!resp.ok) return;
const msgs = await resp.json();
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null);
const result = JSON.parse(resultStr);
dbg('Decrypted (KeyExchange) from:', result.sender);
dbg('Poll got', msgs.length, 'messages, sessions:', Object.keys(sessions));
const senderFP = normFP(result.sender);
sessions[senderFP] = { data: result.session_data };
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
for (let i = 0; i < msgs.length; i++) {
const b64 = msgs[i];
let fromLabel = result.sender.slice(0, 19);
try {
const ar = await fetch(SERVER + '/v1/alias/whois/' + senderFP);
const ad = await ar.json();
if (ad.alias) fromLabel = '@' + ad.alias;
} catch(e) {}
addMsg(fromLabel, result.text, false);
decrypted = true;
} catch(e) {
dbg('KeyExchange failed:', e.message || e);
}
// Second try: existing sessions
if (!decrypted) {
for (const [senderFP, sessData] of Object.entries(sessions)) {
try {
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
dbg('Msg', i, ':', bytes.length, 'bytes, first 4:', Array.from(bytes.slice(0, 4)));
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, sessData.data);
const result = JSON.parse(resultStr);
dbg('Decrypted with session', senderFP);
// First try: KeyExchange (no existing session needed)
let decrypted = false;
sessions[senderFP] = { data: result.session_data };
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
let fromLabel = result.sender.slice(0, 19);
try {
dbg('Trying decrypt as KeyExchange (no session)...');
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null);
const result = JSON.parse(resultStr);
dbg('Decrypted!', result.new_session ? 'new session' : 'existing', 'from:', result.sender);
const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender));
const ad = await ar.json();
if (ad.alias) fromLabel = '@' + ad.alias;
} catch(e2) {}
const senderFP = normFP(result.sender);
sessions[senderFP] = { data: result.session_data };
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
let fromLabel = result.sender.slice(0, 19);
try {
const ar = await fetch(SERVER + '/v1/alias/whois/' + senderFP);
const ad = await ar.json();
if (ad.alias) fromLabel = '@' + ad.alias;
} catch(e) {}
addMsg(fromLabel, result.text, false);
decrypted = true;
} catch(e) {
dbg('KeyExchange decrypt failed:', e.message || e);
}
// Second try: existing sessions
if (!decrypted) {
for (const [senderFP, sessData] of Object.entries(sessions)) {
try {
dbg('Trying session for', senderFP);
const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, sessData.data);
const result = JSON.parse(resultStr);
dbg('Decrypted with session', senderFP, ':', result.text.slice(0, 30));
sessions[senderFP] = { data: result.session_data };
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
let fromLabel = result.sender.slice(0, 19);
try {
const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender));
const ad = await ar.json();
if (ad.alias) fromLabel = '@' + ad.alias;
} catch(e2) {}
addMsg(fromLabel, result.text, false);
decrypted = true;
break;
} catch(e2) {
dbg('Session', senderFP, 'failed:', e2.message || e2);
}
}
}
if (!decrypted) {
dbg('ALL decrypt attempts failed for msg', i);
addSys('[message could not be decrypted]');
}
} catch(e) {
dbg('Message processing error:', e);
addSys('[failed to process message]');
addMsg(fromLabel, result.text, false);
decrypted = true;
break;
} catch(e2) {
dbg('Session', senderFP, 'failed:', e2.message || e2);
}
}
} catch(e) { /* server offline */ }
}
if (!decrypted) {
dbg('ALL decrypt attempts failed');
addSys('[message could not be decrypted]');
}
}
// Load saved sessions
@@ -467,7 +489,7 @@ async function enterChat() {
const savedPeer = localStorage.getItem('wz-peer');
if (savedPeer) $peerInput.value = savedPeer;
pollTimer = setInterval(pollMessages, 2000);
connectWebSocket();
$input.focus();
}