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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user