/*! * Data Network JS SDK v0.1.0 * https://app.datanet.art/sdk/datanet.browser.js * * (c) Studio Jordan Shaw * Browser-ready build — exposes window.DataNet after loading. * * Usage: * * */ (function (global) { "use strict"; class DataNet { constructor(options) { if (!options || !options.apiKey) { throw new Error("DataNet: apiKey is required"); } this.apiKey = options.apiKey; this.deviceId = options.deviceId || null; this.clientId = options.clientId || null; this.deviceName = options.deviceName || null; this.apiUrl = options.apiUrl || "https://api.datanet.art"; this.wsUrl = options.wsUrl || "wss://ws.datanet.art"; this.maxReconnectAttempts = options.maxReconnectAttempts || 5; this._jwt = null; this._jwtExpiry = null; this._ws = null; this._handlers = new Map(); // channel → Set this._listeners = new Map(); // event → Set this._heartbeatTimer = null; this._reconnectTimer = null; this._refreshTimer = null; this._reconnectAttempts = 0; this._intentionalClose = false; } // ── Event emitter ──────────────────────────────────────────── on(event, handler) { let set = this._listeners.get(event); if (!set) { set = new Set(); this._listeners.set(event, set); } set.add(handler); return this; } off(event, handler) { this._listeners.get(event)?.delete(handler); return this; } _emit(event, ...args) { this._listeners.get(event)?.forEach(h => h(...args)); } // ── Connection ─────────────────────────────────────────────── async connect() { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); let res; try { res = await fetch(this.apiUrl + "/auth/token", { method: "POST", headers: { "Content-Type": "application/json" }, signal: controller.signal, body: JSON.stringify({ apiKey: this.apiKey, ...(this.deviceId ? { deviceId: this.deviceId } : {}), ...(this.clientId ? { clientId: this.clientId } : {}), ...(this.deviceName ? { deviceName: this.deviceName } : {}), }), }); } catch (error) { clearTimeout(timeout); const err = new Error( error instanceof Error && error.name === "AbortError" ? "DataNet: authentication timed out" : "DataNet: authentication request failed" ); this._emit("error", err); throw err; } clearTimeout(timeout); if (!res.ok) { let detail = ""; try { const body = await res.json(); if (body && body.error) detail = ": " + body.error; } catch {} const err = new Error("DataNet: authentication failed (" + res.status + " " + res.statusText + ")" + detail); this._emit("error", err); throw err; } const json = await res.json(); this._jwt = json.token; this._jwtExpiry = this._parseJwtExp(this._jwt); this._scheduleTokenRefresh(); await this._openSocket(); } _openSocket() { if (!this._jwt) return Promise.resolve(); this._intentionalClose = false; return new Promise((resolve, reject) => { let handshakeComplete = false; const ws = new WebSocket(this.wsUrl + "/ws", ["bearer", this._jwt]); this._ws = ws; ws.addEventListener("open", () => { this._reconnectAttempts = 0; this._startHeartbeat(); }); ws.addEventListener("message", (event) => { const data = event.data; const finish = (text) => { if (!handshakeComplete) { try { const envelope = JSON.parse(text); if (envelope.type === "connected") { handshakeComplete = true; this._handlers.forEach((_, ch) => this._send({ op: "sub", ch })); this._emit("connect"); resolve(); return; } if (envelope.type === "error" && envelope.error) { const err = new Error("DataNet: " + envelope.error); this._emit("error", err); reject(err); return; } } catch {} } this._handleMessage(text); }; if (typeof data === "string") { finish(data); } else if (data instanceof ArrayBuffer) { finish(new TextDecoder().decode(data)); } else if (data instanceof Blob) { data.text().then(text => finish(text)); } }); ws.addEventListener("close", () => { this._stopHeartbeat(); this._emit("disconnect"); if (!handshakeComplete) { reject(new Error("DataNet: connection closed before handshake completed")); return; } if (!this._intentionalClose) this._scheduleReconnect(); }); ws.addEventListener("error", (event) => { this._emit("error", event); if (!handshakeComplete) { reject(new Error("DataNet: websocket connection failed")); } }); }); } _handleMessage(raw) { let envelope; try { envelope = JSON.parse(raw); } catch { return; } if (envelope.op === "pub" && envelope.ch) { const set = this._handlers.get(envelope.ch); if (set) { const meta = { channel: envelope.ch, from: envelope.from || "", timestamp: envelope.ts || Date.now(), }; set.forEach(h => h(envelope.d, meta)); } } // Surface gateway-level errors (e.g. channel_not_allowed) if (envelope.type === "error" && envelope.error) { this._emit("error", new Error("DataNet: " + envelope.error + (envelope.channel ? " (" + envelope.channel + ")" : ""))); } } _send(envelope) { if (this._ws && this._ws.readyState === WebSocket.OPEN) { this._ws.send(JSON.stringify(envelope)); } } // ── Heartbeat ──────────────────────────────────────────────── _startHeartbeat() { this._stopHeartbeat(); this._heartbeatTimer = setInterval(() => this._send({ op: "hb" }), 30000); } _stopHeartbeat() { if (this._heartbeatTimer !== null) { clearInterval(this._heartbeatTimer); this._heartbeatTimer = null; } } // ── Reconnect ──────────────────────────────────────────────── _scheduleReconnect() { if (this._reconnectAttempts >= this.maxReconnectAttempts) { this._emit("error", new Error("DataNet: max reconnect attempts (" + this.maxReconnectAttempts + ") reached")); return; } const delay = Math.min(1000 * Math.pow(2, this._reconnectAttempts), 30000); this._reconnectAttempts++; this._reconnectTimer = setTimeout(() => { if (this._jwt && !this._isJwtExpired()) { this._openSocket(); } else { this.connect().catch(() => {}); } }, delay); } // ── Pub / Sub ──────────────────────────────────────────────── subscribe(channel, handler) { let set = this._handlers.get(channel); if (!set) { set = new Set(); this._handlers.set(channel, set); this._send({ op: "sub", ch: channel }); } set.add(handler); return this; } unsubscribe(channel, handler) { if (!handler) { this._handlers.delete(channel); this._send({ op: "unsub", ch: channel }); } else { const set = this._handlers.get(channel); if (set) { set.delete(handler); if (set.size === 0) { this._handlers.delete(channel); this._send({ op: "unsub", ch: channel }); } } } return this; } publish(channel, data) { this._send({ op: "pub", ch: channel, d: data }); return this; } // ── Lifecycle ──────────────────────────────────────────────── disconnect() { this._intentionalClose = true; if (this._reconnectTimer !== null) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; } if (this._refreshTimer !== null) { clearTimeout(this._refreshTimer); this._refreshTimer = null; } this._stopHeartbeat(); if (this._ws) { this._ws.close(); this._ws = null; } this._jwt = null; this._jwtExpiry = null; } _parseJwtExp(token) { try { const payload = JSON.parse(atob(token.split(".")[1])); return typeof payload.exp === "number" ? payload.exp : null; } catch { return null; } } _isJwtExpired() { if (!this._jwtExpiry) return false; return Date.now() >= (this._jwtExpiry - 10) * 1000; } _scheduleTokenRefresh() { if (this._refreshTimer !== null) { clearTimeout(this._refreshTimer); this._refreshTimer = null; } if (!this._jwtExpiry) return; const refreshAt = (this._jwtExpiry - 90) * 1000; const delay = refreshAt - Date.now(); if (delay <= 0) return; this._refreshTimer = setTimeout(() => { this._silentRefresh().catch(() => {}); }, delay); } async _silentRefresh() { try { const res = await fetch(this.apiUrl + "/auth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey: this.apiKey, ...(this.deviceId ? { deviceId: this.deviceId } : {}), ...(this.clientId ? { clientId: this.clientId } : {}), ...(this.deviceName ? { deviceName: this.deviceName } : {}), }), }); if (!res.ok) return; const json = await res.json(); this._jwt = json.token; this._jwtExpiry = this._parseJwtExp(this._jwt); this._scheduleTokenRefresh(); } catch { // Will retry naturally when the connection drops and reconnect re-auths } } get connected() { return this._ws !== null && this._ws.readyState === WebSocket.OPEN; } } global.DataNet = DataNet; }(typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : this));