Phase 2/4 done, working on phase 3/5

This commit is contained in:
Martino Ferrari
2026-04-24 15:46:04 +02:00
parent 9aa89cc0cf
commit 8b548ba1c2
43 changed files with 6800 additions and 156 deletions
+206
View File
@@ -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();