feat(web): add browser web client with IndexedDB, Service Worker, and PWA

Create sdks/web/ with a vanilla TypeScript web client:
- IndexedDB local store for conversations, messages, and identity
- WebSocket transport connecting to the server bridge
- Service Worker with cache-first strategy for offline support
- PWA manifest for installable web app
- Dark-themed responsive UI with sidebar, messages, and input bar
- Connection status badge and MLS epoch indicator in header
- Unread message count badges on conversations
This commit is contained in:
2026-03-04 20:55:05 +01:00
parent 496f83067a
commit 2d56824834
8 changed files with 1049 additions and 0 deletions

17
sdks/web/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#58a6ff" />
<title>quicproquo</title>
<link rel="manifest" href="manifest.json" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="app">
<!-- Login screen -->
<div id="login-screen" class="screen">
<h1>quicproquo</h1>
<p class="subtitle">End-to-end encrypted messenger</p>
<form id="login-form">
<input id="server-input" type="text" placeholder="Server (ws://host:port)" value="ws://127.0.0.1:9000" />
<input id="username-input" type="text" placeholder="Username" autocomplete="username" />
<input id="password-input" type="password" placeholder="Password" autocomplete="current-password" />
<button type="submit" id="login-btn">Connect & Login</button>
<button type="button" id="register-btn">Register</button>
</form>
<p id="login-error" class="error" hidden></p>
</div>
<!-- Chat screen -->
<div id="chat-screen" class="screen" hidden>
<aside id="sidebar">
<div id="sidebar-header">
<span id="user-display"></span>
<button id="logout-btn" title="Logout">Logout</button>
</div>
<ul id="conversation-list"></ul>
<button id="new-dm-btn">+ New DM</button>
</aside>
<main id="chat-main">
<header id="chat-header">
<span id="chat-title">Select a conversation</span>
<span id="epoch-badge" title="MLS epoch">epoch --</span>
<span id="conn-badge" class="offline">Offline</span>
</header>
<div id="messages"></div>
<form id="send-form">
<input id="msg-input" type="text" placeholder="Type a message..." autocomplete="off" />
<button type="submit">Send</button>
</form>
</main>
</div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

View File

@@ -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"
}
]
}

266
sdks/web/public/style.css Normal file
View File

@@ -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;
}

96
sdks/web/public/sw.js Normal file
View File

@@ -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("/");
}),
);
});

408
sdks/web/src/app.ts Normal file
View File

@@ -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 $ = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const loginScreen = $<HTMLDivElement>("login-screen");
const chatScreen = $<HTMLDivElement>("chat-screen");
const loginForm = $<HTMLFormElement>("login-form");
const serverInput = $<HTMLInputElement>("server-input");
const usernameInput = $<HTMLInputElement>("username-input");
const passwordInput = $<HTMLInputElement>("password-input");
const loginBtn = $<HTMLButtonElement>("login-btn");
const registerBtn = $<HTMLButtonElement>("register-btn");
const loginError = $<HTMLParagraphElement>("login-error");
const userDisplay = $<HTMLSpanElement>("user-display");
const logoutBtn = $<HTMLButtonElement>("logout-btn");
const conversationList = $<HTMLUListElement>("conversation-list");
const newDmBtn = $<HTMLButtonElement>("new-dm-btn");
const chatTitle = $<HTMLSpanElement>("chat-title");
const epochBadge = $<HTMLSpanElement>("epoch-badge");
const connBadge = $<HTMLSpanElement>("conn-badge");
const messagesDiv = $<HTMLDivElement>("messages");
const sendForm = $<HTMLFormElement>("send-form");
const msgInput = $<HTMLInputElement>("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<void> {
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<string, unknown>): Promise<unknown> {
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();

170
sdks/web/src/store.ts Normal file
View File

@@ -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<IDBDatabase> {
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<void> {
this.db = await openDB();
}
close(): void {
if (this.db) {
this.db.close();
this.db = null;
}
}
// --- Identity ---
async saveIdentity(identity: StoredIdentity): Promise<void> {
const db = this.requireDB();
const tx = db.transaction("identity", "readwrite");
tx.objectStore("identity").put(identity);
return txDone(tx);
}
async loadIdentity(username: string): Promise<StoredIdentity | null> {
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<void> {
const db = this.requireDB();
const tx = db.transaction("conversations", "readwrite");
tx.objectStore("conversations").put(conv);
return txDone(tx);
}
async listConversations(): Promise<StoredConversation[]> {
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<StoredConversation | null> {
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<void> {
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<StoredMessage[]> {
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<void> {
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<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}

17
sdks/web/tsconfig.json Normal file
View File

@@ -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"]
}