// Thin client for the Cipher Worker API + the mailbox WebSocket. let token = null; export function setToken(t) { token = t; } async function req(method, path, body, raw = false) { const headers = {}; if (token) headers["Authorization"] = "Bearer " + token; const opts = { method, headers }; if (body !== undefined) { if (raw) { opts.body = body; // ArrayBuffer / Blob } else { headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); } } const res = await fetch(path, opts); if (!res.ok) { let msg = res.statusText; try { msg = (await res.json()).error || msg; } catch {} throw new Error(msg); } const ct = res.headers.get("Content-Type") || ""; return ct.includes("application/json") ? res.json() : res; } export const api = { register: (payload) => req("POST", "/api/auth/register", payload), login: (payload) => req("POST", "/api/auth/login", payload), lookup: (username) => req("GET", "/api/users/" + encodeURIComponent(username)), bundle: (userId) => req("GET", "/api/keys/bundle/" + userId), keyCount: () => req("GET", "/api/keys/count"), addPreKeys: (oneTimePreKeys) => req("POST", "/api/keys/prekeys", { oneTimePreKeys }), resetKeys: (signedPreKey, oneTimePreKeys) => req("POST", "/api/keys/reset", { signedPreKey, oneTimePreKeys }), send: (toUserId, messages) => req("POST", "/api/messages/send", { toUserId, messages }), // recovery backup (server stores/serves only ciphertext) putBackup: (payload) => req("POST", "/api/backup", { payload }), getBackup: () => req("GET", "/api/backup"), backupExists: () => req("GET", "/api/backup/exists"), // QR provisioning relay provisionNew: (ephemeralPub) => req("POST", "/api/provision/new", { ephemeralPub }), provisionGet: (id) => req("GET", "/api/provision/" + id), provisionComplete: (id, payload) => req("POST", "/api/provision/" + id + "/complete", { payload }), uploadMedia: async (ciphertext) => { const res = await req("POST", "/api/media", ciphertext, true); return res.json ? res.json() : res; }, fetchMedia: async (key) => { const res = await req("GET", "/api/media/" + key); return res.arrayBuffer(); }, }; // ---- WebSocket with auto-reconnect + ack ---- export class MailboxSocket { constructor(tok, onEnvelope) { this.tok = tok; this.onEnvelope = onEnvelope; this.ws = null; this.backoff = 1000; this.closed = false; this.pending = []; // envelope ids awaiting ack batch } connect() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; this.ws = new WebSocket(`${proto}//${location.host}/api/ws?token=${encodeURIComponent(this.tok)}`); this.ws.onopen = () => { this.backoff = 1000; this.ws.send(JSON.stringify({ t: "pull" })); }; this.ws.onmessage = async (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.t === "message" && msg.envelope) { try { await this.onEnvelope(msg.envelope); this.ack(msg.envelope.id); } catch (e) { console.error("envelope handling failed", e); } } }; this.ws.onclose = () => { if (this.closed) return; setTimeout(() => this.connect(), this.backoff); this.backoff = Math.min(this.backoff * 2, 30000); }; this.ws.onerror = () => this.ws && this.ws.close(); } ack(id) { // Batch acks briefly to reduce chatter. this.pending.push(id); clearTimeout(this._ackTimer); this._ackTimer = setTimeout(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN && this.pending.length) { this.ws.send(JSON.stringify({ t: "ack", ids: this.pending })); this.pending = []; } }, 150); } close() { this.closed = true; if (this.ws) this.ws.close(); } }