- Hono Worker: auth, key directory (X3DH prekey bundles), message relay, R2 media - Mailbox Durable Object: WebSocket delivery + offline ciphertext queue - PWA client: libsignal (vendored), IndexedDB key store, chat UI, media E2E - Server only ever holds public keys + ciphertext; private keys stay on device Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
108 lines
3.2 KiB
JavaScript
108 lines
3.2 KiB
JavaScript
// 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 }),
|
|
send: (toUserId, messages) => req("POST", "/api/messages/send", { toUserId, messages }),
|
|
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();
|
|
}
|
|
}
|