/*!
* 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));