merge socket bug fix from staging and accepted incoming changes
This commit is contained in:
commit
473510e205
2 changed files with 84 additions and 38 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
import { pond } from "protobuf-ts/pond";
|
import { pond } from "protobuf-ts/pond";
|
||||||
import { useEffect, useRef, useCallback, useMemo } from "react";
|
import { useEffect, useRef, useMemo } from "react";
|
||||||
import { getWsBaseUrl } from "utils/getWsBaseUrl";
|
import { getWsBaseUrl } from "utils/getWsBaseUrl";
|
||||||
|
|
||||||
/** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */
|
/** Off by default to reduce server load; set VITE_ENABLE_WEBSOCKETS=true to enable live streams. */
|
||||||
|
|
@ -36,10 +36,47 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
|
||||||
const wsMapRef = useRef<Map<string, WebSocket>>(new Map());
|
const wsMapRef = useRef<Map<string, WebSocket>>(new Map());
|
||||||
const retriesRef = useRef<Map<string, number>>(new Map());
|
const retriesRef = useRef<Map<string, number>>(new Map());
|
||||||
const reconnectTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
const reconnectTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
/** Bumps only in effect cleanup so late onclose / reconnect timers never run after unmount. */
|
||||||
|
const generationRef = useRef(0);
|
||||||
|
/** Latest ID set so a device dropped from the list does not reconnect on abnormal close. */
|
||||||
|
const allowedIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
allowedIdsRef.current = new Set(deviceIds);
|
||||||
|
|
||||||
const connect = useCallback(
|
// Normalize for stable comparison: same set of IDs = same string regardless of order
|
||||||
(deviceId: string) => {
|
const deviceIdsKey = useMemo(
|
||||||
|
() => [...new Set(deviceIds)].sort().join(","),
|
||||||
|
[deviceIds.join(",")]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const clearReconnect = (deviceId: string) => {
|
||||||
|
const t = reconnectTimeoutsRef.current.get(deviceId);
|
||||||
|
if (t) {
|
||||||
|
clearTimeout(t);
|
||||||
|
reconnectTimeoutsRef.current.delete(deviceId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shutdownAll = () => {
|
||||||
|
reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t));
|
||||||
|
reconnectTimeoutsRef.current.clear();
|
||||||
|
wsMapRef.current.forEach((ws) => ws.close(1000, "cleanup"));
|
||||||
|
wsMapRef.current.clear();
|
||||||
|
retriesRef.current.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!WEBSOCKETS_ENABLED || !enabled || !token || deviceIds.length === 0) {
|
||||||
|
shutdownAll();
|
||||||
|
return () => {
|
||||||
|
generationRef.current += 1;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gen = generationRef.current;
|
||||||
|
|
||||||
|
const connect = (deviceId: string) => {
|
||||||
if (!token || !deviceId) return;
|
if (!token || !deviceId) return;
|
||||||
|
if (gen !== generationRef.current) return;
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("token", token);
|
params.set("token", token);
|
||||||
|
|
@ -47,18 +84,19 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
|
||||||
if (types?.length) params.set("types", types.toString());
|
if (types?.length) params.set("types", types.toString());
|
||||||
if (as) params.set("as", as);
|
if (as) params.set("as", as);
|
||||||
|
|
||||||
// Match useWebSocket / pond routes: /v1/live/devices/:id/status
|
|
||||||
const path = `/v1/live/devices/${deviceId}/status`;
|
const path = `/v1/live/devices/${deviceId}/status`;
|
||||||
const url = `${getWsBaseUrl()}${path}?${params.toString()}`;
|
const url = `${getWsBaseUrl()}${path}?${params.toString()}`;
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
wsMapRef.current.set(deviceId, ws);
|
wsMapRef.current.set(deviceId, ws);
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
if (gen !== generationRef.current) return;
|
||||||
console.debug(`[ws] connected: ${path}`);
|
console.debug(`[ws] connected: ${path}`);
|
||||||
retriesRef.current.set(deviceId, 0);
|
retriesRef.current.set(deviceId, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
|
if (gen !== generationRef.current) return;
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(event.data);
|
const raw = JSON.parse(event.data);
|
||||||
const status = pond.DeviceStatus.fromObject(raw ?? {});
|
const status = pond.DeviceStatus.fromObject(raw ?? {});
|
||||||
|
|
@ -75,7 +113,15 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
wsMapRef.current.delete(deviceId);
|
wsMapRef.current.delete(deviceId);
|
||||||
|
|
||||||
// Don't reconnect on clean close or auth rejection
|
if (gen !== generationRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedIdsRef.current.has(deviceId)) {
|
||||||
|
retriesRef.current.delete(deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
|
if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -85,55 +131,36 @@ export function useDeviceStatusStreams(options: UseDeviceStatusStreamsOptions) {
|
||||||
retriesRef.current.set(deviceId, retries + 1);
|
retriesRef.current.set(deviceId, retries + 1);
|
||||||
console.debug(`[ws] reconnecting ${path} in ${delay}ms...`);
|
console.debug(`[ws] reconnecting ${path} in ${delay}ms...`);
|
||||||
|
|
||||||
const timeout = setTimeout(() => connect(deviceId), delay);
|
const timeout = setTimeout(() => {
|
||||||
|
if (gen !== generationRef.current) return;
|
||||||
|
if (!allowedIdsRef.current.has(deviceId)) return;
|
||||||
|
reconnectTimeoutsRef.current.delete(deviceId);
|
||||||
|
connect(deviceId);
|
||||||
|
}, delay);
|
||||||
reconnectTimeoutsRef.current.set(deviceId, timeout);
|
reconnectTimeoutsRef.current.set(deviceId, timeout);
|
||||||
};
|
};
|
||||||
},
|
};
|
||||||
[token, keys?.join(), types?.join(), as]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Normalize for stable comparison: same set of IDs = same string regardless of order
|
|
||||||
const deviceIdsKey = useMemo(
|
|
||||||
() => [...new Set(deviceIds)].sort().join(","),
|
|
||||||
[deviceIds.join(",")]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!WEBSOCKETS_ENABLED || !enabled || !token || deviceIds.length === 0) {
|
|
||||||
wsMapRef.current.forEach((ws) => ws.close(1000, "disabled"));
|
|
||||||
wsMapRef.current.clear();
|
|
||||||
reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t));
|
|
||||||
reconnectTimeoutsRef.current.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIds = new Set(deviceIds);
|
const currentIds = new Set(deviceIds);
|
||||||
|
|
||||||
// Connect to new devices
|
|
||||||
deviceIds.forEach((id) => {
|
deviceIds.forEach((id) => {
|
||||||
if (!wsMapRef.current.has(id)) {
|
if (!wsMapRef.current.has(id)) {
|
||||||
connect(id);
|
connect(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Disconnect from devices no longer in the list
|
|
||||||
wsMapRef.current.forEach((ws, id) => {
|
wsMapRef.current.forEach((ws, id) => {
|
||||||
if (!currentIds.has(id)) {
|
if (!currentIds.has(id)) {
|
||||||
|
clearReconnect(id);
|
||||||
ws.close(1000, "device removed");
|
ws.close(1000, "device removed");
|
||||||
wsMapRef.current.delete(id);
|
wsMapRef.current.delete(id);
|
||||||
const t = reconnectTimeoutsRef.current.get(id);
|
retriesRef.current.delete(id);
|
||||||
if (t) {
|
|
||||||
clearTimeout(t);
|
|
||||||
reconnectTimeoutsRef.current.delete(id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
wsMapRef.current.forEach((ws) => ws.close(1000, "cleanup"));
|
generationRef.current += 1;
|
||||||
wsMapRef.current.clear();
|
shutdownAll();
|
||||||
reconnectTimeoutsRef.current.forEach((t) => clearTimeout(t));
|
|
||||||
reconnectTimeoutsRef.current.clear();
|
|
||||||
};
|
};
|
||||||
}, [enabled, token, deviceIdsKey, deviceIds.length, connect]);
|
}, [enabled, token, deviceIdsKey, deviceIds.length, keys?.join(), types?.join(), as]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,14 @@ export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const retriesRef = useRef(0);
|
const retriesRef = useRef(0);
|
||||||
|
/** Bumps on each effect cleanup so late onclose / reconnect timers never run after unmount or dependency change. */
|
||||||
|
const generationRef = useRef(0);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
|
const gen = generationRef.current;
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("token", token);
|
params.set("token", token);
|
||||||
if (rate > 0) params.set("rate", rate.toString());
|
if (rate > 0) params.set("rate", rate.toString());
|
||||||
|
|
@ -85,6 +89,10 @@ export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
|
||||||
console.debug(`[ws] closed: ${path} (code: ${event.code})`);
|
console.debug(`[ws] closed: ${path} (code: ${event.code})`);
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
|
|
||||||
|
if (gen !== generationRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't reconnect on clean close or auth rejection
|
// Don't reconnect on clean close or auth rejection
|
||||||
if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
|
if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -94,22 +102,33 @@ export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
|
||||||
const delay = Math.min(1000 * Math.pow(2, retriesRef.current), 30000);
|
const delay = Math.min(1000 * Math.pow(2, retriesRef.current), 30000);
|
||||||
retriesRef.current += 1;
|
retriesRef.current += 1;
|
||||||
console.debug(`[ws] reconnecting in ${delay}ms...`);
|
console.debug(`[ws] reconnecting in ${delay}ms...`);
|
||||||
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (gen !== generationRef.current) return;
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
};
|
};
|
||||||
}, [path, token, rate, keys?.join(), types?.join(), as]);
|
}, [path, token, rate, keys?.join(), types?.join(), as]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!WEBSOCKETS_ENABLED || !enabled || !token) {
|
if (!WEBSOCKETS_ENABLED || !enabled || !token) {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.close(1000, "disabled");
|
wsRef.current.close(1000, "disabled");
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
return;
|
retriesRef.current = 0;
|
||||||
|
return () => {
|
||||||
|
generationRef.current += 1;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retriesRef.current = 0;
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
generationRef.current += 1;
|
||||||
if (reconnectTimeoutRef.current) {
|
if (reconnectTimeoutRef.current) {
|
||||||
clearTimeout(reconnectTimeoutRef.current);
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue