UNPKG

userdo

Version:

A Durable Object base class for building applications on Cloudflare Workers.

338 lines (337 loc) 12.2 kB
import ReconnectingWebSocket from 'reconnecting-websocket'; class UserDOClient { constructor(baseUrl, options = {}) { this.baseUrl = baseUrl; this.user = null; this.authListeners = new Set(); this.ws = null; this.changeListeners = new Map(); this.options = options; // Log cookie domain configuration for debugging const cookieDomain = this.autoDetectCookieDomain(); if (cookieDomain) { console.log('🍪 UserDO will use cookie domain:', cookieDomain); } this.checkAuthStatus(); } get headers() { const headers = { "Content-Type": "application/json" }; // Automatically handle cross-subdomain cookies const cookieDomain = this.autoDetectCookieDomain(); if (cookieDomain) { headers["X-Cookie-Domain"] = cookieDomain; } // Cookies are automatically sent with requests, no need to manually add Authorization header return headers; } /** * Auto-detect if we're in a cross-subdomain scenario and return the root domain */ autoDetectCookieDomain() { try { // If no baseUrl or it's relative, we're on the same domain if (!this.baseUrl.startsWith('http')) { return null; } const apiUrl = new URL(this.baseUrl); const currentHost = window.location.hostname; const apiHost = apiUrl.hostname; // If hosts are the same, no cross-domain issue if (currentHost === apiHost) { return null; } // Check if they share a common root domain const currentParts = currentHost.split('.'); const apiParts = apiHost.split('.'); // Need at least 2 parts for a valid domain (e.g., example.com) if (currentParts.length < 2 || apiParts.length < 2) { return null; } // Check if the last 2 parts match (e.g., example.com) const currentRoot = currentParts.slice(-2).join('.'); const apiRoot = apiParts.slice(-2).join('.'); if (currentRoot === apiRoot) { return `.${currentRoot}`; // Return with leading dot for cross-subdomain access } return null; } catch (error) { console.warn('Failed to auto-detect cookie domain:', error); return null; } } async checkAuthStatus() { try { // Check if we're authenticated via cookies (same mechanism as server) const url = `${this.baseUrl}/me`; const res = await fetch(url, { credentials: 'include' // Ensure cookies are sent }); if (res.ok) { const data = await res.json(); this.user = data.user; } else { this.user = null; } } catch (error) { console.error('Auth check error:', error); this.user = null; } 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(); } } /** * Builds the WebSocket URL, using custom websocketUrl if provided, * otherwise falling back to constructing from baseUrl and current location */ buildWebSocketUrl() { // Use custom WebSocket URL if provided if (this.options.websocketUrl) { return this.options.websocketUrl; } // Default behavior: construct from baseUrl and current location const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.host}${this.baseUrl}/ws`; } connectWebSocket() { if (this.ws) return; const wsUrl = this.buildWebSocketUrl(); 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;