userdo
Version:
A Durable Object base class for building applications on Cloudflare Workers.
338 lines (337 loc) • 12.2 kB
JavaScript
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;