UNPKG

userdo

Version:

A Durable Object base class that provides user authentication, per-user data storage, and real-time updates for Cloudflare Workers applications.

287 lines (286 loc) 10.3 kB
import ReconnectingWebSocket from 'reconnecting-websocket'; class UserDOClient { constructor(baseUrl) { this.baseUrl = baseUrl; this.user = null; this.authListeners = new Set(); this.ws = null; this.changeListeners = new Map(); this.checkAuthStatus(); } get headers() { const headers = { "Content-Type": "application/json" }; // Cookies are automatically sent with requests, no need to manually add Authorization header return headers; } async checkAuthStatus() { console.log('🔍 Checking auth status...'); try { // Check if we're authenticated via cookies (same mechanism as server) const url = `${this.baseUrl}/me`; console.log('🔍 Fetching auth status from:', url); const res = await fetch(url, { credentials: 'include' // Ensure cookies are sent }); console.log('🔍 Auth response status:', res.status); if (res.ok) { const data = await res.json(); console.log('🔍 Auth response data:', data); this.user = data.user; } else { const errorText = await res.text(); console.log('🔍 Auth failed, response:', errorText); this.user = null; } } catch (error) { console.error('🔍 Auth check error:', error); this.user = null; } console.log('🔍 Final auth state:', this.user); this.emitAuthChange(); } emitAuthChange() { this.authListeners.forEach((l) => l(this.user)); console.log('🔐 Auth state changed:', { user: this.user ? this.user.email : 'none' }); // Connect/disconnect WebSocket based on auth state if (this.user && !this.ws) { console.log('🔌 Triggering WebSocket connection...'); this.connectWebSocket(); } else if (!this.user && this.ws) { console.log('🔌 Disconnecting WebSocket (user logged out)...'); this.disconnectWebSocket(); } } connectWebSocket() { if (this.ws) return; // Build WebSocket URL from current page origin const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}${this.baseUrl}/ws`; console.log('🔌 Connecting to WebSocket:', wsUrl); // Use ReconnectingWebSocket for automatic reconnection this.ws = new ReconnectingWebSocket(wsUrl); this.ws.onopen = () => { console.log('🔌 WebSocket connected'); }; this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data); this.handleRealtimeMessage(message); } catch (error) { console.error('WebSocket message error:', error); } }; this.ws.onclose = () => { console.log('🔌 WebSocket disconnected'); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); }; } disconnectWebSocket() { if (this.ws) { this.ws.close(); this.ws = null; } } handleRealtimeMessage(message) { const listeners = this.changeListeners.get(message.event); if (listeners) { listeners.forEach(listener => { try { listener(message.data); } catch (error) { console.error('Change listener error:', error); } }); } } onAuthStateChanged(listener) { this.authListeners.add(listener); listener(this.user); } offAuthStateChanged(listener) { this.authListeners.delete(listener); } async signup(email, password) { const res = await fetch(`${this.baseUrl}/signup`, { method: "POST", headers: this.headers, credentials: 'include', body: JSON.stringify({ email, password }) }); if (!res.ok) throw new Error(await res.text()); const data = (await res.json()); this.user = data.user; this.emitAuthChange(); return data; } async login(email, password) { const res = await fetch(`${this.baseUrl}/login`, { method: "POST", headers: this.headers, credentials: 'include', body: JSON.stringify({ email, password }) }); if (!res.ok) throw new Error(await res.text()); const data = (await res.json()); this.user = data.user; this.emitAuthChange(); return data; } async logout() { await fetch(`${this.baseUrl}/logout`, { method: "POST", headers: this.headers, credentials: 'include' }); this.user = null; this.disconnectWebSocket(); this.emitAuthChange(); } // KV Storage methods async get(key) { const res = await fetch(`${this.baseUrl.replace('/api', '')}/data?key=${encodeURIComponent(key)}`, { headers: this.headers, credentials: 'include' }); if (!res.ok) throw new Error(await res.text()); const data = await res.json(); return data.data; } async set(key, value) { const res = await fetch(`${this.baseUrl.replace('/api', '')}/data`, { method: "POST", headers: this.headers, credentials: 'include', body: JSON.stringify({ key, value }) }); if (!res.ok) throw new Error(await res.text()); return { ok: true }; } // Watch KV changes onChange(key, listener) { const eventKey = `kv:${key}`; if (!this.changeListeners.has(eventKey)) { this.changeListeners.set(eventKey, new Set()); } this.changeListeners.get(eventKey).add(listener); console.log(`🔌 Watching KV key: ${key}`); // Return unsubscribe function return () => { const listeners = this.changeListeners.get(eventKey); if (listeners) { listeners.delete(listener); if (listeners.size === 0) { this.changeListeners.delete(eventKey); } } console.log(`🔌 Stopped watching KV key: ${key}`); }; } collection(name) { const base = `${this.baseUrl}/${name}`; const client = this; return { async create(data) { const res = await fetch(base, { method: "POST", headers: client.headers, credentials: 'include', body: JSON.stringify(data) }); if (!res.ok) throw new Error(await res.text()); return res.json(); }, async findById(id) { const res = await fetch(`${base}/${id}`, { headers: client.headers, credentials: 'include' }); if (!res.ok) throw new Error(await res.text()); return res.json(); }, async update(id, updates) { const res = await fetch(`${base}/${id}`, { method: "PUT", headers: client.headers, credentials: 'include', body: JSON.stringify(updates) }); if (!res.ok) throw new Error(await res.text()); return res.json(); }, async delete(id) { await fetch(`${base}/${id}`, { method: "DELETE", headers: client.headers, credentials: 'include' }); }, // Watch collection changes onChange(listener) { const eventKey = `table:${name}`; if (!client.changeListeners.has(eventKey)) { client.changeListeners.set(eventKey, new Set()); } client.changeListeners.get(eventKey).add(listener); console.log(`🔌 Watching collection: ${name}`); // Return unsubscribe function return () => { const listeners = client.changeListeners.get(eventKey); if (listeners) { listeners.delete(listener); if (listeners.size === 0) { client.changeListeners.delete(eventKey); } } console.log(`🔌 Stopped watching collection: ${name}`); }; }, query() { const params = {}; return { where(field, op, value) { params["where"] = JSON.stringify([field, op, value]); return this; }, orderBy(field, dir = "asc") { params["order"] = `${field}:${dir}`; return this; }, limit(count) { params["limit"] = count; return this; }, async get() { const qs = new URLSearchParams(params).toString(); const res = await fetch(`${base}?${qs}`, { headers: client.headers, credentials: 'include' }); if (!res.ok) throw new Error(await res.text()); return res.json(); } }; } }; } } export { UserDOClient }; export default UserDOClient;