This commit is contained in:
RGJorge
2026-05-07 22:09:10 +00:00
parent d18a459a57
commit 5872eff157
12 changed files with 585 additions and 142 deletions
+160
View File
@@ -0,0 +1,160 @@
import { describe, it, expect } from "vitest";
import { applyProcessing, arraysEqual, type ProcessingEntry } from "./processing";
import type { Service } from "../../shared/types";
function makeSvc(overrides: Partial<Service> = {}): Service {
return {
id: "abc123",
uid: "proj/svc",
name: "svc",
image: "node:20",
state: "running",
status: "Up 5 minutes",
ports: [],
networks: ["default"],
network_ips: {},
project: "proj",
compose_file: "",
env: [],
restart_policy: "",
memory_limit: 0,
cpu_quota: 0,
health_status: "",
health_log: [],
exit_code: 0,
restart_count: 0,
oom_killed: false,
...overrides,
};
}
describe("arraysEqual", () => {
it("returns true for identical arrays", () => {
const a = [makeSvc({ uid: "a", state: "running" })];
expect(arraysEqual(a, a)).toBe(true);
});
it("returns false for different lengths", () => {
const a = [makeSvc()];
expect(arraysEqual(a, [])).toBe(false);
});
it("returns false when uid differs", () => {
const a = [makeSvc({ uid: "a" })];
const b = [makeSvc({ uid: "b" })];
expect(arraysEqual(a, b)).toBe(false);
});
it("returns false when state differs", () => {
const a = [makeSvc({ uid: "a", state: "running" })];
const b = [makeSvc({ uid: "a", state: "exited" })];
expect(arraysEqual(a, b)).toBe(false);
});
it("returns true when uid and state match despite other differences", () => {
const a = [makeSvc({ uid: "a", state: "running", image: "node:18" })];
const b = [makeSvc({ uid: "a", state: "running", image: "node:20" })];
expect(arraysEqual(a, b)).toBe(true);
});
});
describe("applyProcessing", () => {
it("returns raw unchanged when no processing entries", () => {
const raw = [makeSvc()];
const processing = new Map<string, ProcessingEntry>();
expect(applyProcessing(raw, processing)).toBe(raw);
});
it("shows processing state when minDuration has not elapsed", () => {
const now = 10000;
const raw = [makeSvc({ uid: "proj/svc", state: "exited" })];
const processing = new Map<string, ProcessingEntry>([
["proj/svc", { expected: "running", startedAt: 9000, minDuration: 2000 }],
]);
const result = applyProcessing(raw, processing, now);
expect(result[0].state).toBe("processing");
// Entry should NOT be deleted yet
expect(processing.has("proj/svc")).toBe(true);
});
it("clears processing when state matches expected after minDuration", () => {
const now = 12000;
const raw = [makeSvc({ uid: "proj/svc", state: "running" })];
const processing = new Map<string, ProcessingEntry>([
["proj/svc", { expected: "running", startedAt: 9000, minDuration: 2000 }],
]);
const result = applyProcessing(raw, processing, now);
expect(result[0].state).toBe("running");
expect(processing.has("proj/svc")).toBe(false);
});
it("crashed matches expected exited (stop bug)", () => {
const now = 12000;
const raw = [makeSvc({ uid: "proj/svc", state: "crashed" })];
const processing = new Map<string, ProcessingEntry>([
["proj/svc", { expected: "exited", startedAt: 9000, minDuration: 2000 }],
]);
const result = applyProcessing(raw, processing, now);
expect(result[0].state).toBe("crashed");
expect(processing.has("proj/svc")).toBe(false);
});
it("dead matches expected exited", () => {
const now = 12000;
const raw = [makeSvc({ uid: "proj/svc", state: "dead" })];
const processing = new Map<string, ProcessingEntry>([
["proj/svc", { expected: "exited", startedAt: 9000, minDuration: 2000 }],
]);
const result = applyProcessing(raw, processing, now);
expect(result[0].state).toBe("dead");
expect(processing.has("proj/svc")).toBe(false);
});
it("crashed clears processing when expected is running (crash on start)", () => {
const now = 12000;
const raw = [makeSvc({ uid: "proj/svc", state: "crashed" })];
const processing = new Map<string, ProcessingEntry>([
["proj/svc", { expected: "running", startedAt: 9000, minDuration: 2000 }],
]);
const result = applyProcessing(raw, processing, now);
expect(result[0].state).toBe("crashed");
expect(processing.has("proj/svc")).toBe(false);
});
it("15s timeout clears processing as safety net", () => {
const startedAt = 1000;
const now = startedAt + 16000; // > 15s
const raw = [makeSvc({ uid: "proj/svc", state: "exited" })];
const processing = new Map<string, ProcessingEntry>([
["proj/svc", { expected: "running", startedAt, minDuration: 0 }],
]);
const result = applyProcessing(raw, processing, now);
expect(result[0].state).toBe("exited");
expect(processing.has("proj/svc")).toBe(false);
});
it("keeps processing when state does not match and within timeout", () => {
const now = 5000;
const raw = [makeSvc({ uid: "proj/svc", state: "exited" })];
const processing = new Map<string, ProcessingEntry>([
["proj/svc", { expected: "running", startedAt: 3000, minDuration: 0 }],
]);
const result = applyProcessing(raw, processing, now);
expect(result[0].state).toBe("processing");
expect(processing.has("proj/svc")).toBe(true);
});
it("does not affect services without processing entries", () => {
const now = 5000;
const raw = [
makeSvc({ uid: "proj/a", state: "running" }),
makeSvc({ uid: "proj/b", state: "exited" }),
];
const processing = new Map<string, ProcessingEntry>([
["proj/a", { expected: "exited", startedAt: 3000, minDuration: 0 }],
]);
const result = applyProcessing(raw, processing, now);
expect(result[0].state).toBe("processing"); // a is processing
expect(result[1].state).toBe("exited"); // b unchanged
});
});
+50
View File
@@ -0,0 +1,50 @@
import type { Service } from "../../shared/types";
export interface ProcessingEntry {
expected: Service["state"];
startedAt: number;
minDuration: number;
}
export function arraysEqual(a: Service[], b: Service[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].uid !== b[i].uid) return false;
if (a[i].state !== b[i].state) return false;
}
return true;
}
export function applyProcessing(
raw: Service[],
processing: Map<string, ProcessingEntry>,
now = Date.now(),
): Service[] {
if (processing.size === 0) return raw;
return raw.map((s: Service) => {
const entry = processing.get(s.uid);
if (!entry) return s;
const elapsed = now - entry.startedAt;
if (elapsed < entry.minDuration) {
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
}
if (s.state === entry.expected) {
processing.delete(s.uid);
return s;
}
// Stop/remove expects "exited" but Docker may report "crashed" or "dead" (non-zero exit from SIGTERM/SIGKILL)
if (entry.expected === "exited" && (s.state === "crashed" || s.state === "dead" || s.state === "exited")) {
processing.delete(s.uid);
return s;
}
if (s.state === "crashed" && entry.expected === "running") {
processing.delete(s.uid);
return s;
}
if (now - entry.startedAt > 15000) {
processing.delete(s.uid);
return s;
}
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
});
}
+50 -49
View File
@@ -1,15 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { Service, Connection, Stats, DockerEvent, LogLine, WSMessage } from "../../shared/types";
import type { StatsStore } from "./useStatsStore";
function arraysEqual(a: Service[], b: Service[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].uid !== b[i].uid) return false;
if (a[i].state !== b[i].state) return false;
}
return true;
}
import { arraysEqual, applyProcessing as applyProcessingPure } from "./processing";
export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (pos: Record<string, { x: number; y: number }>) => void) {
const [services, setServices] = useState<Service[]>([]);
@@ -19,6 +11,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
const [logLines, setLogLines] = useState<LogLine[]>([]);
// Processing state: uid → { expected state, start time, min duration before clearing }
const processingRef = useRef<Map<string, { expected: Service["state"]; startedAt: number; minDuration: number }>>(new Map());
const processingIntervalsRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
const lastRawServicesRef = useRef<Service[]>([]);
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
@@ -26,30 +19,7 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
// Apply processing overlay to raw services data
const applyProcessing = useCallback((raw: Service[]): Service[] => {
const processing = processingRef.current;
if (processing.size === 0) return raw;
const now = Date.now();
return raw.map((s: Service) => {
const entry = processing.get(s.uid);
if (!entry) return s;
const elapsed = now - entry.startedAt;
if (elapsed < entry.minDuration) {
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
}
if (s.state === entry.expected) {
processing.delete(s.uid);
return s;
}
if (s.state === "crashed" && entry.expected === "running") {
processing.delete(s.uid);
return s;
}
if (now - entry.startedAt > 15000) {
processing.delete(s.uid);
return s;
}
return { ...s, state: "processing" as any, _processingStartedAt: entry.startedAt } as any;
});
return applyProcessingPure(raw, processingRef.current);
}, []);
// Single init call: services + connections + positions
@@ -144,7 +114,15 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
break;
case "action_error": {
processingRef.current.delete(msg.data.uid);
setServices((prev) => [...prev]);
const clearInterval_ = processingIntervalsRef.current.get(msg.data.uid);
if (clearInterval_) { clearInterval(clearInterval_); processingIntervalsRef.current.delete(msg.data.uid); }
const raw = lastRawServicesRef.current;
if (raw.length > 0) {
const incoming = applyProcessing(raw);
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
} else {
setServices((prev) => prev.map((s) => s.uid === msg.data.uid ? { ...s, state: "exited" as any } : s));
}
break;
}
}
@@ -178,6 +156,11 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
wsRef.current.onclose = null;
wsRef.current.close();
}
// Clean up all processing intervals
for (const interval of processingIntervalsRef.current.values()) {
clearInterval(interval);
}
processingIntervalsRef.current.clear();
};
}, [connect]);
@@ -196,25 +179,43 @@ export function useDocker(token = "", statsStore?: StatsStore, onPositions?: (po
processingRef.current.set(uid, { expected: expectedState, startedAt, minDuration });
actionTimestamps.current.set(uid, Math.floor(startedAt / 1000));
setServices((prev) => prev.map((s) => s.uid === uid ? { ...s, state: "processing" as any, _processingStartedAt: startedAt } as any : s));
// Re-evaluate every 1s after minDuration until processing clears.
// Handles the case where the server stops broadcasting (hash unchanged).
if (minDuration > 0) {
const interval = setInterval(() => {
if (!processingRef.current.has(uid)) { clearInterval(interval); return; }
const elapsed = Date.now() - startedAt;
if (elapsed < minDuration) return;
const incoming = applyProcessing(lastRawServicesRef.current);
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
// applyProcessing deletes the entry when resolved or timed out (15s)
if (!processingRef.current.has(uid)) clearInterval(interval);
}, 1000);
}
// Clear any existing interval for this uid (e.g. rapid re-clicks)
const prevInterval = processingIntervalsRef.current.get(uid);
if (prevInterval) clearInterval(prevInterval);
// Re-evaluate every 1s until processing clears.
// Always create interval — even for minDuration=0 — so the 15s timeout safety net works
// when the server stops broadcasting (hash unchanged).
const interval = setInterval(() => {
if (!processingRef.current.has(uid)) {
clearInterval(interval);
processingIntervalsRef.current.delete(uid);
return;
}
const elapsed = Date.now() - startedAt;
if (elapsed < minDuration) return;
const raw = lastRawServicesRef.current;
if (raw.length === 0) return;
const incoming = applyProcessing(raw);
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
if (!processingRef.current.has(uid)) {
clearInterval(interval);
processingIntervalsRef.current.delete(uid);
}
}, 1000);
processingIntervalsRef.current.set(uid, interval);
}, [applyProcessing]);
const clearProcessing = useCallback((uid: string) => {
processingRef.current.delete(uid);
setServices((prev) => [...prev]);
}, []);
const interval = processingIntervalsRef.current.get(uid);
if (interval) { clearInterval(interval); processingIntervalsRef.current.delete(uid); }
const raw = lastRawServicesRef.current;
if (raw.length > 0) {
const incoming = applyProcessing(raw);
setServices((prev) => arraysEqual(prev, incoming) ? prev : incoming);
}
}, [applyProcessing]);
const getLogsSince = useCallback((uid: string): number | undefined => {
return actionTimestamps.current.get(uid);
+8 -5
View File
@@ -113,6 +113,9 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
onAction(service.uid, expectedState, minDuration);
setInitialLogs([]);
clearLogLines();
// Unsubscribe log stream to prevent stale lines during the action
sendMessage({ type: "unsubscribe_logs" });
subscribedRef.current = null;
try {
const headers: Record<string, string> = {};
@@ -135,7 +138,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
setActionLoading(null);
setTimeout(() => setActionResult(null), 3000);
}
}, [service.id, service.uid, token, onAction, clearProcessing]);
}, [service.id, service.uid, token, onAction, clearProcessing, sendMessage, clearLogLines]);
const runExec = useCallback(async () => {
if (!execCmd.trim()) return;
@@ -174,8 +177,7 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const since = actionTimestampRef.current;
const sinceParam = since ? `&since=${since}` : "";
fetch(`/api/logs/${service.id}?tail=200${sinceParam}`, { headers })
fetch(`/api/logs/${service.id}?tail=200&since=${since || Math.floor(Date.now() / 1000)}`, { headers })
.then((r) => r.ok ? r.json() : [])
.then((lines: LogLine[]) => setInitialLogs(lines))
.catch(() => {});
@@ -259,10 +261,11 @@ export function DetailPanel({ service, stats, logLines, token, closing, onClose,
setAutoScroll((prev) => prev === atBottom ? prev : atBottom);
}, []);
// Docker events as special log lines
// Docker events as special log lines (only show events since last action, or all if no action)
const eventLogLines = useMemo(() => {
const sinceTs = actionTimestampRef.current;
return events
.filter((e) => e.service === service.uid)
.filter((e) => e.service === service.uid && (!sinceTs || e.time >= sinceTs))
.map((e): LogLine => ({
container: service.id,
line: `[DOCKER] Container ${e.action}`,
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect } from "vitest";
import { discoverConnections, isInfraService, isProxyService, isWorkerService } from "./docker";
import type { Service } from "../shared/types";
function makeSvc(overrides: Partial<Service> = {}): Service {
return {
id: "abc123",
uid: "proj/svc",
name: "svc",
image: "node:20",
state: "running",
status: "Up 5 minutes",
ports: [],
networks: ["default"],
network_ips: {},
project: "proj",
compose_file: "",
env: [],
restart_policy: "",
memory_limit: 0,
cpu_quota: 0,
health_status: "",
health_log: [],
exit_code: 0,
restart_count: 0,
oom_killed: false,
...overrides,
};
}
describe("isInfraService", () => {
it("detects postgres by image", () => {
expect(isInfraService(makeSvc({ image: "postgres:16" }))).toEqual({ type: "database", label: "postgres" });
});
it("detects redis by name", () => {
expect(isInfraService(makeSvc({ name: "redis-cache" }))).toEqual({ type: "cache", label: "redis" });
});
it("returns null for app service", () => {
expect(isInfraService(makeSvc({ name: "backend", image: "node:20" }))).toBeNull();
});
});
describe("isProxyService", () => {
it("detects nginx", () => {
expect(isProxyService(makeSvc({ image: "nginx:latest" }))).toBe(true);
});
it("detects traefik by name", () => {
expect(isProxyService(makeSvc({ name: "traefik" }))).toBe(true);
});
it("returns false for app service", () => {
expect(isProxyService(makeSvc({ name: "api", image: "node:20" }))).toBe(false);
});
});
describe("isWorkerService", () => {
it("detects celery worker", () => {
expect(isWorkerService(makeSvc({ name: "celery-worker" }))).toBe(true);
});
it("detects beat scheduler", () => {
expect(isWorkerService(makeSvc({ name: "celery-beat" }))).toBe(true);
});
it("returns false for app service", () => {
expect(isWorkerService(makeSvc({ name: "api" }))).toBe(false);
});
});
describe("discoverConnections", () => {
it("connects app to infra in same network", async () => {
const services = [
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["backend"] }),
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
];
const conns = await discoverConnections(services);
expect(conns).toEqual([
{ from: "proj/api", to: "proj/db", network: "", type: "database", label: "postgres" },
]);
});
it("does not connect services in different networks", async () => {
const services = [
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["frontend"] }),
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
];
const conns = await discoverConnections(services);
expect(conns).toEqual([]);
});
it("connects proxy to app in same network", async () => {
const services = [
makeSvc({ uid: "proj/nginx", name: "nginx", image: "nginx:latest", networks: ["frontend"] }),
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["frontend"] }),
];
const conns = await discoverConnections(services);
expect(conns).toEqual([
{ from: "proj/nginx", to: "proj/api", network: "", type: "proxy", label: "upstream" },
]);
});
it("connects worker to infra in same network", async () => {
const services = [
makeSvc({ uid: "proj/worker", name: "celery-worker", image: "app:latest", networks: ["backend"] }),
makeSvc({ uid: "proj/redis", name: "redis", image: "redis:7", networks: ["backend"] }),
];
const conns = await discoverConnections(services);
expect(conns).toEqual([
{ from: "proj/worker", to: "proj/redis", network: "", type: "cache", label: "broker" },
]);
});
it("deduplicates connections", async () => {
// A collector service that is also an app — should only get one connection to db
const services = [
makeSvc({ uid: "proj/collector", name: "collector", image: "node:20", networks: ["backend"] }),
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
];
const conns = await discoverConnections(services);
// collector is both an app (not infra/proxy/worker) AND matches the collector rule
// but deduplication should prevent duplicates
const keys = conns.map((c) => `${c.from}:${c.to}`);
expect(new Set(keys).size).toBe(keys.length);
});
it("handles full stack with multiple service types", async () => {
const services = [
makeSvc({ uid: "proj/nginx", name: "nginx", image: "nginx:latest", networks: ["frontend", "backend"] }),
makeSvc({ uid: "proj/api", name: "api", image: "node:20", networks: ["frontend", "backend"] }),
makeSvc({ uid: "proj/db", name: "db", image: "postgres:16", networks: ["backend"] }),
makeSvc({ uid: "proj/redis", name: "redis", image: "redis:7", networks: ["backend"] }),
makeSvc({ uid: "proj/worker", name: "celery-worker", image: "app:latest", networks: ["backend"] }),
];
const conns = await discoverConnections(services);
const has = (from: string, to: string) => conns.some((c) => c.from === from && c.to === to);
expect(has("proj/api", "proj/db")).toBe(true);
expect(has("proj/api", "proj/redis")).toBe(true);
expect(has("proj/nginx", "proj/api")).toBe(true);
expect(has("proj/worker", "proj/redis")).toBe(true);
expect(has("proj/worker", "proj/db")).toBe(true);
// proxy should NOT connect to infra
expect(has("proj/nginx", "proj/db")).toBe(false);
expect(has("proj/nginx", "proj/redis")).toBe(false);
});
});
+12 -6
View File
@@ -4,7 +4,9 @@ import type { Service, Connection, LogLine } from "../shared/types";
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
if (!fs.existsSync(DOCKER_SOCKET)) {
const isTest = process.env.NODE_ENV === "test" || !!process.env.VITEST;
if (!isTest && !fs.existsSync(DOCKER_SOCKET)) {
console.error(`\n Error: Docker socket not found at ${DOCKER_SOCKET}`);
console.error(` Make sure Docker is running or set DOCKER_SOCKET env var.\n`);
process.exit(1);
@@ -107,7 +109,7 @@ const INFRA_PATTERNS: { pattern: string; type: string; label: string; role: "tar
const PROXY_PATTERNS = ["nginx", "traefik", "haproxy", "caddy", "envoy"];
function isInfraService(svc: Service): { type: string; label: string } | null {
export function isInfraService(svc: Service): { type: string; label: string } | null {
const img = svc.image.toLowerCase();
const name = svc.name.toLowerCase();
for (const p of INFRA_PATTERNS) {
@@ -118,13 +120,13 @@ function isInfraService(svc: Service): { type: string; label: string } | null {
return null;
}
function isProxyService(svc: Service): boolean {
export function isProxyService(svc: Service): boolean {
const img = svc.image.toLowerCase();
const name = svc.name.toLowerCase();
return PROXY_PATTERNS.some((p) => img.includes(p) || name.includes(p));
}
function isWorkerService(svc: Service): boolean {
export function isWorkerService(svc: Service): boolean {
const name = svc.name.toLowerCase();
return name.includes("celery") || name.includes("worker") || name.includes("beat") || name.includes("cron");
}
@@ -201,10 +203,14 @@ export async function getContainerLogs(id: string, tail = 200, since?: number):
const opts: Record<string, any> = {
stdout: true,
stderr: true,
tail,
timestamps: true,
};
if (since) opts.since = since;
if (since) {
// When filtering by time, use since only (no tail limit) — Docker applies tail before since
opts.since = since;
} else {
opts.tail = tail;
}
const logBuffer = await container.logs(opts);
const lines: LogLine[] = [];
+12 -4
View File
@@ -124,7 +124,7 @@ app.post("/api/containers/:id/stop", async (c) => {
try {
const container = docker.getContainer(id);
await container.stop();
// Docker events will trigger refresh automatically when state changes
immediateRefresh();
return c.json({ ok: true });
} catch (err: any) {
if (err?.statusCode === 304) return c.json({ ok: true, message: "Already stopped" });
@@ -138,7 +138,7 @@ app.post("/api/containers/:id/start", async (c) => {
try {
const container = docker.getContainer(id);
await container.start();
// Docker events will trigger refresh automatically when state changes
immediateRefresh();
return c.json({ ok: true });
} catch (err: any) {
if (err?.statusCode === 304) return c.json({ ok: true, message: "Already running" });
@@ -152,7 +152,7 @@ app.post("/api/containers/:id/restart", async (c) => {
try {
const container = docker.getContainer(id);
await container.restart();
// Docker events will trigger refresh automatically when state changes
immediateRefresh();
return c.json({ ok: true });
} catch (err: any) {
return c.json({ error: err?.message || "Failed to restart container" }, 500);
@@ -464,6 +464,14 @@ function scheduleRefresh() {
}, 500);
}
// Immediate refresh after action endpoints (container already changed state)
function immediateRefresh() {
lastServicesHash = "";
clearTimeout(refreshTimer);
clearTimeout(retryTimer);
refreshServices();
}
watchDockerEvents((event) => {
broadcast({ type: "docker_event", data: event });
scheduleRefresh();
@@ -516,7 +524,7 @@ const server = Bun.serve({
},
message(ws, message) {
try {
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as ArrayBuffer));
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message as unknown as ArrayBuffer));
const native = ws as unknown as WebSocket;
// Handle authentication via first message