Multi-user collaboration for the current ComfyUI frontend, continued from ComfyUI-Nexus by daxcay (MIT). - Login page before ComfyUI loads, first-run admin setup, JWT sessions - Personal API keys; server-side permission checks (edit, queue, manager, admin) - Every workflow shared as a live room, with snapshots and history - Per-workflow access lists (view / edit per person) - Conflict-free concurrent edits (id remapping, per-id link merges) - Presence: who is online, cursors, selections, follow mode - Chat with unread badge, blinking icon and sound - Server monitor (CPU, RAM, GPUs, queue) with a floating widget - Admin options to hide the Comfy.org login and API templates - Docs, changelog, screenshots and icon
216 lines
6.7 KiB
JavaScript
216 lines
6.7 KiB
JavaScript
// Connection, identity and HTTP helpers shared by every RigShare module.
|
|
|
|
const LS = {
|
|
token: "rigshare.token",
|
|
guestId: "rigshare.guestId",
|
|
guestName: "rigshare.guestName",
|
|
};
|
|
|
|
function storageGet(key) {
|
|
try { return localStorage.getItem(key); } catch { return null; }
|
|
}
|
|
|
|
function storageSet(key, value) {
|
|
try {
|
|
if (value == null) localStorage.removeItem(key);
|
|
else localStorage.setItem(key, value);
|
|
} catch { /* private mode */ }
|
|
}
|
|
|
|
function randomId() {
|
|
return crypto.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
}
|
|
|
|
export class RigClient extends EventTarget {
|
|
constructor() {
|
|
super();
|
|
this.ws = null;
|
|
this.status = "offline"; // offline | connecting | online | denied
|
|
this.self = null;
|
|
this.users = [];
|
|
this.chat = [];
|
|
this.rooms = [];
|
|
this.stats = null;
|
|
this.server = {};
|
|
this.retry = 0;
|
|
this.token = storageGet(LS.token);
|
|
this.guestId = storageGet(LS.guestId) || randomId();
|
|
storageSet(LS.guestId, this.guestId);
|
|
this.guestName = storageGet(LS.guestName) || "";
|
|
}
|
|
|
|
get perms() {
|
|
return this.self?.perms ?? { edit: false, queue: false, admin: false };
|
|
}
|
|
|
|
get online() {
|
|
return this.status === "online";
|
|
}
|
|
|
|
emit(type, detail) {
|
|
this.dispatchEvent(new CustomEvent(type, { detail }));
|
|
}
|
|
|
|
on(type, fn) {
|
|
this.addEventListener(type, (e) => fn(e.detail));
|
|
}
|
|
|
|
// ----- websocket ------------------------------------------------------
|
|
|
|
connect() {
|
|
clearTimeout(this.reconnectTimer);
|
|
const base = location.pathname.replace(/[^/]*$/, "");
|
|
const url = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}${base}rigshare/ws`;
|
|
this.setStatus("connecting");
|
|
const ws = new WebSocket(url);
|
|
this.ws = ws;
|
|
ws.onopen = () => {
|
|
this.retry = 0;
|
|
this.hello();
|
|
};
|
|
ws.onmessage = (e) => {
|
|
let msg;
|
|
try { msg = JSON.parse(e.data); } catch { return; }
|
|
this.handle(msg);
|
|
};
|
|
ws.onclose = () => {
|
|
if (this.ws !== ws) return;
|
|
if (this.status !== "denied") this.setStatus("offline");
|
|
const delay = Math.min(15000, 1000 * 2 ** this.retry++);
|
|
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
};
|
|
}
|
|
|
|
hello() {
|
|
this.send({ type: "hello", token: this.token, guest_id: this.guestId, name: this.guestName });
|
|
}
|
|
|
|
send(msg) {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify(msg));
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
setStatus(status) {
|
|
if (this.status === status) return;
|
|
this.status = status;
|
|
this.emit("status", status);
|
|
}
|
|
|
|
handle(msg) {
|
|
switch (msg.type) {
|
|
case "welcome":
|
|
this.self = msg.self;
|
|
this.users = msg.users;
|
|
this.chat = msg.chat || [];
|
|
this.rooms = msg.rooms || [];
|
|
this.stats = msg.stats;
|
|
this.server = msg.server || {};
|
|
this.setStatus("online");
|
|
this.emit("self", this.self);
|
|
this.emit("presence", this.users);
|
|
this.emit("chat-history", this.chat);
|
|
this.emit("welcome", msg);
|
|
this.emit("server", this.server);
|
|
break;
|
|
case "denied":
|
|
this.self = null;
|
|
this.denyReason = msg.reason;
|
|
this.setStatus("denied");
|
|
this.emit("self", null);
|
|
if (msg.login) this.gotoLogin();
|
|
break;
|
|
case "self":
|
|
this.self = msg.self;
|
|
this.emit("self", this.self);
|
|
break;
|
|
case "logged_out":
|
|
this.setToken(null);
|
|
this.gotoLogin();
|
|
this.emit("toast", { severity: "warn", summary: "RigShare", detail: "Your session ended. You are now a guest." });
|
|
break;
|
|
case "presence":
|
|
this.users = msg.users;
|
|
this.rooms = msg.rooms || this.rooms;
|
|
this.emit("presence", this.users);
|
|
break;
|
|
case "server":
|
|
this.server = msg.server || {};
|
|
this.emit("server", this.server);
|
|
break;
|
|
case "stats":
|
|
this.stats = msg.stats;
|
|
this.emit("stats", msg.stats);
|
|
break;
|
|
case "chat":
|
|
this.chat.push(msg.message);
|
|
if (this.chat.length > 500) this.chat.shift();
|
|
this.emit("chat", msg.message);
|
|
break;
|
|
default:
|
|
this.emit(msg.type, msg);
|
|
}
|
|
}
|
|
|
|
// ----- identity -------------------------------------------------------
|
|
|
|
setToken(token) {
|
|
this.token = token;
|
|
storageSet(LS.token, token);
|
|
}
|
|
|
|
async login(username, password) {
|
|
const res = await this.request("POST", "/rigshare/api/login", { username, password });
|
|
this.setToken(res.token);
|
|
this.reconnect();
|
|
return res.user;
|
|
}
|
|
|
|
async logout() {
|
|
try { await this.request("POST", "/rigshare/api/logout"); } catch { /* ignore */ }
|
|
this.setToken(null);
|
|
this.gotoLogin();
|
|
}
|
|
|
|
gotoLogin() {
|
|
const base = location.pathname.replace(/[^/]*$/, "");
|
|
const next = encodeURIComponent(location.pathname + location.search + location.hash);
|
|
location.replace(`${base}rigshare/login?next=${next}`);
|
|
}
|
|
|
|
rename(name) {
|
|
this.guestName = name;
|
|
storageSet(LS.guestName, name);
|
|
this.send({ type: "rename", name });
|
|
}
|
|
|
|
reconnect() {
|
|
const ws = this.ws;
|
|
this.ws = null;
|
|
this.status = "offline";
|
|
ws?.close();
|
|
this.connect();
|
|
}
|
|
|
|
// Headers that identify this browser to the ComfyUI HTTP API.
|
|
authHeaders() {
|
|
if (this.token) return { "X-RigShare-Token": this.token };
|
|
return { "X-RigShare-Guest": this.guestId };
|
|
}
|
|
|
|
async request(method, path, body) {
|
|
const base = location.pathname.replace(/[^/]*$/, "");
|
|
const res = await fetch(base + path.replace(/^\//, ""), {
|
|
method,
|
|
headers: { "Content-Type": "application/json", ...this.authHeaders() },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
let data = null;
|
|
try { data = await res.json(); } catch { /* empty */ }
|
|
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
|
return data;
|
|
}
|
|
}
|