Phase 2/4 done, working on phase 3/5
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
import { readable, writable, type Readable } from 'svelte/store';
|
||||
import type { SignalRef, SignalValue, SignalMeta } from './types';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type WsStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
interface SubscriberEntry {
|
||||
onUpdate: (v: SignalValue) => void;
|
||||
onMeta: (m: SignalMeta) => void;
|
||||
}
|
||||
|
||||
// ── WsClient ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class WsClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private url = '';
|
||||
|
||||
// Reconnect back-off state
|
||||
private retryDelay = 500;
|
||||
private readonly maxDelay = 30_000;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private intentionalClose = false;
|
||||
|
||||
// Status store
|
||||
private _statusStore = writable<WsStatus>('connecting');
|
||||
readonly status: Readable<WsStatus> = { subscribe: this._statusStore.subscribe };
|
||||
|
||||
// Subscription tracking: key is "ds\0name"
|
||||
// ref-count: how many callers are subscribed
|
||||
private refCounts = new Map<string, number>();
|
||||
// subscriber callbacks per signal
|
||||
private subscribers = new Map<string, Set<SubscriberEntry>>();
|
||||
|
||||
connect(url: string): void {
|
||||
this.url = url;
|
||||
this.intentionalClose = false;
|
||||
this._open();
|
||||
}
|
||||
|
||||
private _open(): void {
|
||||
if (this.intentionalClose) return;
|
||||
|
||||
this._statusStore.set('connecting');
|
||||
|
||||
try {
|
||||
// Resolve relative URL to absolute ws:// URL
|
||||
const wsUrl = this._resolveWsUrl(this.url);
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
} catch {
|
||||
this._scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.addEventListener('open', () => {
|
||||
this.retryDelay = 500; // reset back-off on success
|
||||
this._statusStore.set('connected');
|
||||
|
||||
// Re-subscribe all active signals after reconnect
|
||||
const signals: Array<{ ds: string; name: string }> = [];
|
||||
for (const key of this.refCounts.keys()) {
|
||||
if ((this.refCounts.get(key) ?? 0) > 0) {
|
||||
const [ds, name] = key.split('\0');
|
||||
signals.push({ ds, name });
|
||||
}
|
||||
}
|
||||
if (signals.length > 0) {
|
||||
this._send({ type: 'subscribe', signals });
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('message', (ev) => {
|
||||
this._handleMessage(ev.data as string);
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
this._statusStore.set('disconnected');
|
||||
if (!this.intentionalClose) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('error', () => {
|
||||
// The close event fires right after, which will handle the reconnect.
|
||||
this._statusStore.set('disconnected');
|
||||
});
|
||||
}
|
||||
|
||||
private _resolveWsUrl(path: string): string {
|
||||
if (path.startsWith('ws://') || path.startsWith('wss://')) {
|
||||
return path;
|
||||
}
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${location.host}${path}`;
|
||||
}
|
||||
|
||||
private _scheduleReconnect(): void {
|
||||
if (this.retryTimer !== null) return;
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
this.retryDelay = Math.min(this.retryDelay * 2, this.maxDelay);
|
||||
this._open();
|
||||
}, this.retryDelay);
|
||||
}
|
||||
|
||||
private _send(obj: unknown): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(obj));
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMessage(data: string): void {
|
||||
let msg: any;
|
||||
try {
|
||||
msg = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = msg.ds && msg.name ? `${msg.ds}\0${msg.name}` : '';
|
||||
|
||||
switch (msg.type) {
|
||||
case 'update': {
|
||||
const subs = this.subscribers.get(key);
|
||||
if (!subs) break;
|
||||
const val: SignalValue = {
|
||||
value: msg.value,
|
||||
quality: msg.quality ?? 'unknown',
|
||||
ts: msg.ts ?? null,
|
||||
};
|
||||
for (const s of subs) s.onUpdate(val);
|
||||
break;
|
||||
}
|
||||
case 'meta': {
|
||||
const subs = this.subscribers.get(key);
|
||||
if (!subs || !msg.meta) break;
|
||||
const meta: SignalMeta = {
|
||||
type: msg.meta.type ?? 'unknown',
|
||||
unit: msg.meta.unit,
|
||||
displayLow: msg.meta.displayLow ?? 0,
|
||||
displayHigh: msg.meta.displayHigh ?? 100,
|
||||
writable: msg.meta.writable ?? false,
|
||||
enumStrings: msg.meta.enumStrings,
|
||||
description: msg.meta.description,
|
||||
};
|
||||
for (const s of subs) s.onMeta(meta);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
console.warn('[ws] server error', msg.code, msg.message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a signal. Reference-counted: only one WS subscribe message
|
||||
* is sent even if multiple callers subscribe to the same signal.
|
||||
* Returns a cleanup function that unsubscribes when all callers are done.
|
||||
*/
|
||||
subscribe(
|
||||
ref: SignalRef,
|
||||
onUpdate: (v: SignalValue) => void,
|
||||
onMeta: (m: SignalMeta) => void,
|
||||
): () => void {
|
||||
const key = `${ref.ds}\0${ref.name}`;
|
||||
const entry: SubscriberEntry = { onUpdate, onMeta };
|
||||
|
||||
// Add to subscriber set
|
||||
let set = this.subscribers.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.subscribers.set(key, set);
|
||||
}
|
||||
set.add(entry);
|
||||
|
||||
// Reference counting — send WS subscribe only on first subscriber
|
||||
const prev = this.refCounts.get(key) ?? 0;
|
||||
this.refCounts.set(key, prev + 1);
|
||||
if (prev === 0) {
|
||||
this._send({ type: 'subscribe', signals: [{ ds: ref.ds, name: ref.name }] });
|
||||
}
|
||||
|
||||
return () => {
|
||||
const subs = this.subscribers.get(key);
|
||||
subs?.delete(entry);
|
||||
|
||||
const count = (this.refCounts.get(key) ?? 0) - 1;
|
||||
this.refCounts.set(key, Math.max(0, count));
|
||||
|
||||
if (count <= 0) {
|
||||
this.subscribers.delete(key);
|
||||
this.refCounts.delete(key);
|
||||
this._send({ type: 'unsubscribe', signals: [{ ds: ref.ds, name: ref.name }] });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
write(ref: SignalRef, value: any): void {
|
||||
this._send({ type: 'write', ds: ref.ds, name: ref.name, value });
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const wsClient = new WsClient();
|
||||
Reference in New Issue
Block a user