diff --git a/sdks/web/package.json b/sdks/web/package.json
new file mode 100644
index 0000000..9e16fc3
--- /dev/null
+++ b/sdks/web/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@quicproquo/web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "tsc && cp -r public/* dist/",
+ "dev": "tsc --watch",
+ "clean": "rm -rf dist"
+ },
+ "dependencies": {
+ "@quicproquo/client": "file:../typescript"
+ },
+ "devDependencies": {
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/sdks/web/public/index.html b/sdks/web/public/index.html
new file mode 100644
index 0000000..a9d0269
--- /dev/null
+++ b/sdks/web/public/index.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+ quicproquo
+
+
+
+
+
+
+
+
quicproquo
+
End-to-end encrypted messenger
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sdks/web/public/manifest.json b/sdks/web/public/manifest.json
new file mode 100644
index 0000000..3b80a0d
--- /dev/null
+++ b/sdks/web/public/manifest.json
@@ -0,0 +1,21 @@
+{
+ "name": "quicproquo",
+ "short_name": "qpq",
+ "description": "End-to-end encrypted group messenger",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#0d1117",
+ "theme_color": "#58a6ff",
+ "icons": [
+ {
+ "src": "icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/sdks/web/public/style.css b/sdks/web/public/style.css
new file mode 100644
index 0000000..491b9f4
--- /dev/null
+++ b/sdks/web/public/style.css
@@ -0,0 +1,266 @@
+/* quicproquo web client — dark theme */
+:root {
+ --bg: #0d1117;
+ --bg-surface: #161b22;
+ --bg-input: #21262d;
+ --fg: #c9d1d9;
+ --fg-muted: #8b949e;
+ --accent: #58a6ff;
+ --accent-dim: #1f6feb;
+ --green: #3fb950;
+ --red: #f85149;
+ --yellow: #d29922;
+ --border: #30363d;
+ --radius: 6px;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--fg);
+ height: 100vh;
+ overflow: hidden;
+}
+
+#app {
+ height: 100vh;
+ display: flex;
+}
+
+/* --- Login screen --- */
+.screen { width: 100%; }
+
+#login-screen {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+}
+
+#login-screen h1 {
+ font-size: 2rem;
+ color: var(--accent);
+}
+
+.subtitle {
+ color: var(--fg-muted);
+ margin-bottom: 1rem;
+}
+
+#login-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ width: 320px;
+}
+
+input[type="text"],
+input[type="password"] {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0.6rem 0.8rem;
+ color: var(--fg);
+ font-size: 0.95rem;
+}
+
+input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+button {
+ background: var(--accent-dim);
+ color: var(--fg);
+ border: none;
+ border-radius: var(--radius);
+ padding: 0.6rem 1rem;
+ cursor: pointer;
+ font-size: 0.95rem;
+}
+
+button:hover {
+ background: var(--accent);
+ color: var(--bg);
+}
+
+.error {
+ color: var(--red);
+ font-size: 0.85rem;
+}
+
+/* --- Chat screen --- */
+#chat-screen {
+ display: flex;
+ height: 100vh;
+}
+
+#sidebar {
+ width: 260px;
+ background: var(--bg-surface);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+}
+
+#sidebar-header {
+ padding: 0.75rem;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+#user-display {
+ color: var(--accent);
+ font-weight: 600;
+}
+
+#logout-btn {
+ background: transparent;
+ color: var(--fg-muted);
+ font-size: 0.8rem;
+ padding: 0.3rem 0.5rem;
+}
+
+#conversation-list {
+ list-style: none;
+ flex: 1;
+ overflow-y: auto;
+}
+
+#conversation-list li {
+ padding: 0.6rem 0.75rem;
+ cursor: pointer;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+#conversation-list li:hover {
+ background: var(--bg-input);
+}
+
+#conversation-list li.active {
+ background: var(--bg-input);
+ border-left: 3px solid var(--accent);
+}
+
+.conv-name { flex: 1; }
+
+.unread-badge {
+ background: var(--accent-dim);
+ color: var(--fg);
+ border-radius: 10px;
+ padding: 0.1rem 0.5rem;
+ font-size: 0.75rem;
+ min-width: 1.2rem;
+ text-align: center;
+}
+
+#new-dm-btn {
+ margin: 0.5rem;
+ background: transparent;
+ border: 1px dashed var(--border);
+ color: var(--fg-muted);
+}
+
+/* --- Chat main area --- */
+#chat-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+#chat-header {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+#chat-title {
+ flex: 1;
+ font-weight: 600;
+}
+
+#epoch-badge {
+ font-size: 0.8rem;
+ color: var(--fg-muted);
+ background: var(--bg-input);
+ padding: 0.15rem 0.5rem;
+ border-radius: var(--radius);
+}
+
+#conn-badge {
+ font-size: 0.8rem;
+ padding: 0.15rem 0.5rem;
+ border-radius: var(--radius);
+}
+
+#conn-badge.online {
+ background: var(--green);
+ color: var(--bg);
+}
+
+#conn-badge.offline {
+ background: var(--red);
+ color: var(--fg);
+}
+
+#messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.message {
+ max-width: 70%;
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--radius);
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+}
+
+.message.outgoing {
+ align-self: flex-end;
+ background: var(--accent-dim);
+ border-color: var(--accent-dim);
+}
+
+.message .meta {
+ font-size: 0.75rem;
+ color: var(--fg-muted);
+ margin-bottom: 0.25rem;
+}
+
+.message.outgoing .meta {
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.message .body {
+ word-break: break-word;
+}
+
+#send-form {
+ display: flex;
+ padding: 0.5rem;
+ border-top: 1px solid var(--border);
+ gap: 0.5rem;
+}
+
+#msg-input {
+ flex: 1;
+}
diff --git a/sdks/web/public/sw.js b/sdks/web/public/sw.js
new file mode 100644
index 0000000..fec7358
--- /dev/null
+++ b/sdks/web/public/sw.js
@@ -0,0 +1,96 @@
+/**
+ * quicproquo Service Worker — provides offline caching and background
+ * notification support for the PWA.
+ */
+
+const CACHE_NAME = "qpq-cache-v1";
+const STATIC_ASSETS = [
+ "/",
+ "/index.html",
+ "/style.css",
+ "/app.js",
+ "/manifest.json",
+];
+
+// Install: cache static assets.
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)),
+ );
+ self.skipWaiting();
+});
+
+// Activate: clean up old caches.
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(
+ keys
+ .filter((key) => key !== CACHE_NAME)
+ .map((key) => caches.delete(key)),
+ ),
+ ),
+ );
+ self.clients.claim();
+});
+
+// Fetch: serve from cache, fall back to network.
+self.addEventListener("fetch", (event) => {
+ // Only cache GET requests for same-origin resources.
+ if (event.request.method !== "GET") return;
+
+ event.respondWith(
+ caches.match(event.request).then((cached) => {
+ if (cached) return cached;
+ return fetch(event.request).then((response) => {
+ // Cache successful same-origin responses.
+ if (
+ response.ok &&
+ new URL(event.request.url).origin === self.location.origin
+ ) {
+ const clone = response.clone();
+ caches.open(CACHE_NAME).then((cache) => {
+ cache.put(event.request, clone);
+ });
+ }
+ return response;
+ });
+ }),
+ );
+});
+
+// Push notifications (future: wired to server push events).
+self.addEventListener("push", (event) => {
+ if (!event.data) return;
+
+ let payload;
+ try {
+ payload = event.data.json();
+ } catch {
+ payload = { title: "quicproquo", body: event.data.text() };
+ }
+
+ event.waitUntil(
+ self.registration.showNotification(payload.title || "quicproquo", {
+ body: payload.body || "New message",
+ icon: "/icon-192.png",
+ badge: "/icon-192.png",
+ tag: payload.conversationId || "default",
+ }),
+ );
+});
+
+// Notification click: open the app.
+self.addEventListener("notificationclick", (event) => {
+ event.notification.close();
+ event.waitUntil(
+ self.clients
+ .matchAll({ type: "window" })
+ .then((clients) => {
+ if (clients.length > 0) {
+ return clients[0].focus();
+ }
+ return self.clients.openWindow("/");
+ }),
+ );
+});
diff --git a/sdks/web/src/app.ts b/sdks/web/src/app.ts
new file mode 100644
index 0000000..06f4b1f
--- /dev/null
+++ b/sdks/web/src/app.ts
@@ -0,0 +1,408 @@
+/**
+ * quicproquo web client — main application.
+ *
+ * Connects the TS SDK, WASM crypto, IndexedDB store, and the DOM together.
+ */
+
+import { LocalStore } from "./store.js";
+import type { StoredConversation, StoredMessage } from "./store.js";
+
+// --- DOM references ---
+const $ = (id: string) =>
+ document.getElementById(id) as T;
+
+const loginScreen = $("login-screen");
+const chatScreen = $("chat-screen");
+const loginForm = $("login-form");
+const serverInput = $("server-input");
+const usernameInput = $("username-input");
+const passwordInput = $("password-input");
+const loginBtn = $("login-btn");
+const registerBtn = $("register-btn");
+const loginError = $("login-error");
+const userDisplay = $("user-display");
+const logoutBtn = $("logout-btn");
+const conversationList = $("conversation-list");
+const newDmBtn = $("new-dm-btn");
+const chatTitle = $("chat-title");
+const epochBadge = $("epoch-badge");
+const connBadge = $("conn-badge");
+const messagesDiv = $("messages");
+const sendForm = $("send-form");
+const msgInput = $("msg-input");
+
+// --- App state ---
+const store = new LocalStore();
+let wsTransport: WebSocket | null = null;
+let currentUser: string | null = null;
+let conversations: StoredConversation[] = [];
+let activeConvId: string | null = null;
+let connected = false;
+
+// --- Init ---
+async function init() {
+ await store.open();
+
+ // Register service worker for PWA offline support.
+ if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.register("sw.js").catch(() => {
+ // Service worker registration failed — non-critical.
+ });
+ }
+
+ loginForm.addEventListener("submit", (e) => {
+ e.preventDefault();
+ handleLogin();
+ });
+ registerBtn.addEventListener("click", handleRegister);
+ logoutBtn.addEventListener("click", handleLogout);
+ sendForm.addEventListener("submit", (e) => {
+ e.preventDefault();
+ handleSend();
+ });
+ newDmBtn.addEventListener("click", handleNewDm);
+}
+
+// --- Auth ---
+async function handleLogin() {
+ const server = serverInput.value.trim();
+ const username = usernameInput.value.trim();
+ const password = passwordInput.value;
+
+ if (!server || !username || !password) {
+ showError("All fields are required");
+ return;
+ }
+
+ loginBtn.disabled = true;
+ hideError();
+
+ try {
+ await connectWS(server);
+ await rpcCall("login", { username, password });
+ currentUser = username;
+ showChat();
+ } catch (err: unknown) {
+ showError(`Login failed: ${err instanceof Error ? err.message : String(err)}`);
+ } finally {
+ loginBtn.disabled = false;
+ }
+}
+
+async function handleRegister() {
+ const server = serverInput.value.trim();
+ const username = usernameInput.value.trim();
+ const password = passwordInput.value;
+
+ if (!server || !username || !password) {
+ showError("All fields are required");
+ return;
+ }
+
+ registerBtn.disabled = true;
+ hideError();
+
+ try {
+ await connectWS(server);
+ await rpcCall("register", { username, password });
+ await rpcCall("login", { username, password });
+ currentUser = username;
+ showChat();
+ } catch (err: unknown) {
+ showError(`Registration failed: ${err instanceof Error ? err.message : String(err)}`);
+ } finally {
+ registerBtn.disabled = false;
+ }
+}
+
+function handleLogout() {
+ currentUser = null;
+ activeConvId = null;
+ if (wsTransport) {
+ wsTransport.close();
+ wsTransport = null;
+ }
+ setConnected(false);
+ loginScreen.hidden = false;
+ chatScreen.hidden = true;
+}
+
+// --- WebSocket transport ---
+let rpcId = 0;
+const pendingCalls = new Map<
+ number,
+ { resolve: (v: unknown) => void; reject: (e: Error) => void }
+>();
+
+function connectWS(addr: string): Promise {
+ return new Promise((resolve, reject) => {
+ if (wsTransport) {
+ wsTransport.close();
+ }
+
+ const ws = new WebSocket(addr);
+ const timeout = setTimeout(() => {
+ reject(new Error("Connection timeout"));
+ ws.close();
+ }, 5000);
+
+ ws.addEventListener("open", () => {
+ clearTimeout(timeout);
+ wsTransport = ws;
+ setConnected(true);
+ resolve();
+ });
+
+ ws.addEventListener("close", () => {
+ clearTimeout(timeout);
+ setConnected(false);
+ rejectAllPending("Connection closed");
+ });
+
+ ws.addEventListener("error", () => {
+ clearTimeout(timeout);
+ reject(new Error("WebSocket error"));
+ });
+
+ ws.addEventListener("message", (ev) => {
+ if (typeof ev.data === "string") {
+ try {
+ const resp = JSON.parse(ev.data);
+ const pending = pendingCalls.get(resp.id);
+ if (pending) {
+ pendingCalls.delete(resp.id);
+ if (resp.ok) {
+ pending.resolve(resp.result);
+ } else {
+ pending.reject(new Error(resp.error ?? "RPC error"));
+ }
+ }
+ // Handle server push events.
+ if (resp.type === "message") {
+ handleIncomingMessage(resp);
+ }
+ } catch {
+ // Ignore malformed messages.
+ }
+ }
+ });
+ });
+}
+
+function rpcCall(method: string, params: Record): Promise {
+ if (!wsTransport || wsTransport.readyState !== WebSocket.OPEN) {
+ return Promise.reject(new Error("Not connected"));
+ }
+
+ const id = ++rpcId;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pendingCalls.delete(id);
+ reject(new Error(`RPC timeout: ${method}`));
+ }, 10000);
+
+ pendingCalls.set(id, {
+ resolve: (v) => {
+ clearTimeout(timer);
+ resolve(v);
+ },
+ reject: (e) => {
+ clearTimeout(timer);
+ reject(e);
+ },
+ });
+
+ wsTransport!.send(JSON.stringify({ id, method, params }));
+ });
+}
+
+function rejectAllPending(reason: string) {
+ for (const [id, p] of pendingCalls) {
+ p.reject(new Error(reason));
+ pendingCalls.delete(id);
+ }
+}
+
+// --- UI updates ---
+function setConnected(value: boolean) {
+ connected = value;
+ connBadge.textContent = value ? "Online" : "Offline";
+ connBadge.className = value ? "online" : "offline";
+}
+
+function showError(msg: string) {
+ loginError.textContent = msg;
+ loginError.hidden = false;
+}
+
+function hideError() {
+ loginError.hidden = true;
+}
+
+async function showChat() {
+ loginScreen.hidden = true;
+ chatScreen.hidden = false;
+ userDisplay.textContent = currentUser ?? "";
+
+ // Load conversations from IndexedDB.
+ conversations = await store.listConversations();
+ renderConversations();
+}
+
+function renderConversations() {
+ conversationList.innerHTML = "";
+ for (const conv of conversations) {
+ const li = document.createElement("li");
+ if (conv.id === activeConvId) li.classList.add("active");
+
+ const nameSpan = document.createElement("span");
+ nameSpan.className = "conv-name";
+ nameSpan.textContent = conv.name;
+ li.appendChild(nameSpan);
+
+ if (conv.unread > 0) {
+ const badge = document.createElement("span");
+ badge.className = "unread-badge";
+ badge.textContent = String(conv.unread);
+ li.appendChild(badge);
+ }
+
+ li.addEventListener("click", () => selectConversation(conv.id));
+ conversationList.appendChild(li);
+ }
+}
+
+async function selectConversation(id: string) {
+ activeConvId = id;
+ const conv = conversations.find((c) => c.id === id);
+ if (conv) {
+ chatTitle.textContent = conv.name;
+ conv.unread = 0;
+ await store.saveConversation(conv);
+ }
+ renderConversations();
+ await renderMessages();
+}
+
+async function renderMessages() {
+ messagesDiv.innerHTML = "";
+ if (!activeConvId) return;
+
+ const msgs = await store.loadMessages(activeConvId);
+ for (const msg of msgs) {
+ appendMessageElement(msg);
+ }
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
+}
+
+function appendMessageElement(msg: StoredMessage) {
+ const div = document.createElement("div");
+ div.className = `message${msg.isOutgoing ? " outgoing" : ""}`;
+
+ const meta = document.createElement("div");
+ meta.className = "meta";
+ const time = new Date(msg.timestamp);
+ const timeStr = `${String(time.getHours()).padStart(2, "0")}:${String(time.getMinutes()).padStart(2, "0")}`;
+ meta.textContent = `${timeStr} ${msg.sender}`;
+
+ const body = document.createElement("div");
+ body.className = "body";
+ body.textContent = msg.body;
+
+ div.appendChild(meta);
+ div.appendChild(body);
+ messagesDiv.appendChild(div);
+}
+
+// --- Send ---
+async function handleSend() {
+ const text = msgInput.value.trim();
+ if (!text || !activeConvId || !currentUser) return;
+
+ msgInput.value = "";
+
+ const msg: StoredMessage = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ conversationId: activeConvId,
+ sender: currentUser,
+ body: text,
+ timestamp: Date.now(),
+ isOutgoing: true,
+ };
+
+ await store.saveMessage(msg);
+ appendMessageElement(msg);
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
+
+ // Send via RPC (best effort).
+ try {
+ await rpcCall("send", {
+ conversationId: activeConvId,
+ body: text,
+ });
+ } catch {
+ // Message saved locally even if RPC fails.
+ }
+}
+
+// --- Receive ---
+async function handleIncomingMessage(data: {
+ conversationId?: string;
+ sender?: string;
+ body?: string;
+}) {
+ if (!data.conversationId || !data.body) return;
+
+ const msg: StoredMessage = {
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ conversationId: data.conversationId,
+ sender: data.sender ?? "unknown",
+ body: data.body,
+ timestamp: Date.now(),
+ isOutgoing: false,
+ };
+
+ await store.saveMessage(msg);
+
+ // Update unread or display.
+ if (data.conversationId === activeConvId) {
+ appendMessageElement(msg);
+ messagesDiv.scrollTop = messagesDiv.scrollHeight;
+ } else {
+ const conv = conversations.find((c) => c.id === data.conversationId);
+ if (conv) {
+ conv.unread += 1;
+ await store.saveConversation(conv);
+ renderConversations();
+ }
+ }
+}
+
+// --- New DM ---
+async function handleNewDm() {
+ const peerName = prompt("Enter username for DM:");
+ if (!peerName) return;
+
+ const convId = `dm-${[currentUser, peerName].sort().join("-")}`;
+
+ // Check if already exists.
+ if (conversations.find((c) => c.id === convId)) {
+ await selectConversation(convId);
+ return;
+ }
+
+ const conv: StoredConversation = {
+ id: convId,
+ name: peerName,
+ unread: 0,
+ lastActivity: Date.now(),
+ };
+
+ await store.saveConversation(conv);
+ conversations.unshift(conv);
+ renderConversations();
+ await selectConversation(convId);
+}
+
+// --- Bootstrap ---
+init();
diff --git a/sdks/web/src/store.ts b/sdks/web/src/store.ts
new file mode 100644
index 0000000..9af35b5
--- /dev/null
+++ b/sdks/web/src/store.ts
@@ -0,0 +1,170 @@
+/**
+ * IndexedDB local state persistence for the web client.
+ *
+ * Stores conversations, messages, and identity state in the browser.
+ */
+
+const DB_NAME = "quicproquo";
+const DB_VERSION = 1;
+
+export interface StoredConversation {
+ id: string;
+ name: string;
+ unread: number;
+ lastActivity: number;
+}
+
+export interface StoredMessage {
+ id: string;
+ conversationId: string;
+ sender: string;
+ body: string;
+ timestamp: number;
+ isOutgoing: boolean;
+}
+
+export interface StoredIdentity {
+ username: string;
+ seed: Uint8Array;
+ publicKey: Uint8Array;
+}
+
+function openDB(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
+
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ if (!db.objectStoreNames.contains("conversations")) {
+ const convStore = db.createObjectStore("conversations", { keyPath: "id" });
+ convStore.createIndex("lastActivity", "lastActivity");
+ }
+ if (!db.objectStoreNames.contains("messages")) {
+ const msgStore = db.createObjectStore("messages", { keyPath: "id" });
+ msgStore.createIndex("conversationId", "conversationId");
+ msgStore.createIndex("timestamp", "timestamp");
+ }
+ if (!db.objectStoreNames.contains("identity")) {
+ db.createObjectStore("identity", { keyPath: "username" });
+ }
+ };
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+}
+
+export class LocalStore {
+ private db: IDBDatabase | null = null;
+
+ async open(): Promise {
+ this.db = await openDB();
+ }
+
+ close(): void {
+ if (this.db) {
+ this.db.close();
+ this.db = null;
+ }
+ }
+
+ // --- Identity ---
+
+ async saveIdentity(identity: StoredIdentity): Promise {
+ const db = this.requireDB();
+ const tx = db.transaction("identity", "readwrite");
+ tx.objectStore("identity").put(identity);
+ return txDone(tx);
+ }
+
+ async loadIdentity(username: string): Promise {
+ const db = this.requireDB();
+ const tx = db.transaction("identity", "readonly");
+ const req = tx.objectStore("identity").get(username);
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => resolve(req.result ?? null);
+ req.onerror = () => reject(req.error);
+ });
+ }
+
+ // --- Conversations ---
+
+ async saveConversation(conv: StoredConversation): Promise {
+ const db = this.requireDB();
+ const tx = db.transaction("conversations", "readwrite");
+ tx.objectStore("conversations").put(conv);
+ return txDone(tx);
+ }
+
+ async listConversations(): Promise {
+ const db = this.requireDB();
+ const tx = db.transaction("conversations", "readonly");
+ const req = tx.objectStore("conversations").index("lastActivity").getAll();
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => resolve((req.result ?? []).reverse());
+ req.onerror = () => reject(req.error);
+ });
+ }
+
+ async getConversation(id: string): Promise {
+ const db = this.requireDB();
+ const tx = db.transaction("conversations", "readonly");
+ const req = tx.objectStore("conversations").get(id);
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => resolve(req.result ?? null);
+ req.onerror = () => reject(req.error);
+ });
+ }
+
+ // --- Messages ---
+
+ async saveMessage(msg: StoredMessage): Promise {
+ const db = this.requireDB();
+ const tx = db.transaction("messages", "readwrite");
+ tx.objectStore("messages").put(msg);
+ return txDone(tx);
+ }
+
+ async loadMessages(conversationId: string, limit = 200): Promise {
+ const db = this.requireDB();
+ const tx = db.transaction("messages", "readonly");
+ const index = tx.objectStore("messages").index("conversationId");
+ const req = index.getAll(conversationId);
+ return new Promise((resolve, reject) => {
+ req.onsuccess = () => {
+ const all: StoredMessage[] = req.result ?? [];
+ all.sort((a, b) => a.timestamp - b.timestamp);
+ resolve(all.slice(-limit));
+ };
+ req.onerror = () => reject(req.error);
+ });
+ }
+
+ // --- Clear ---
+
+ async clearAll(): Promise {
+ const db = this.requireDB();
+ const tx = db.transaction(
+ ["conversations", "messages", "identity"],
+ "readwrite",
+ );
+ tx.objectStore("conversations").clear();
+ tx.objectStore("messages").clear();
+ tx.objectStore("identity").clear();
+ return txDone(tx);
+ }
+
+ private requireDB(): IDBDatabase {
+ if (!this.db) {
+ throw new Error("LocalStore not opened. Call open() first.");
+ }
+ return this.db;
+ }
+}
+
+function txDone(tx: IDBTransaction): Promise {
+ return new Promise((resolve, reject) => {
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
diff --git a/sdks/web/tsconfig.json b/sdks/web/tsconfig.json
new file mode 100644
index 0000000..81fdba3
--- /dev/null
+++ b/sdks/web/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "sourceMap": true,
+ "declaration": false,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}