mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
* Update transcript list on reprocess * Fix transcript create * Fix multiple sockets issue * Pass token in sec websocket protocol * userEvent parse example * transcript list invalidation non-abstraction * Emit only relevant events to the user room * Add ws close code const * Refactor user websocket endpoint * Refactor user events provider --------- Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
181 lines
5.0 KiB
TypeScript
181 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useRef } from "react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import { WEBSOCKET_URL } from "./apiClient";
|
|
import { useAuth } from "./AuthProvider";
|
|
import { z } from "zod";
|
|
import { invalidateTranscriptLists, TRANSCRIPT_SEARCH_URL } from "./apiHooks";
|
|
|
|
const UserEvent = z.object({
|
|
event: z.string(),
|
|
});
|
|
|
|
type UserEvent = z.TypeOf<typeof UserEvent>;
|
|
|
|
class UserEventsStore {
|
|
private socket: WebSocket | null = null;
|
|
private listeners: Set<(event: MessageEvent) => void> = new Set();
|
|
private closeTimeoutId: number | null = null;
|
|
private isConnecting = false;
|
|
|
|
ensureConnection(url: string, subprotocols?: string[]) {
|
|
if (typeof window === "undefined") return;
|
|
if (this.closeTimeoutId !== null) {
|
|
clearTimeout(this.closeTimeoutId);
|
|
this.closeTimeoutId = null;
|
|
}
|
|
if (this.isConnecting) return;
|
|
if (
|
|
this.socket &&
|
|
(this.socket.readyState === WebSocket.OPEN ||
|
|
this.socket.readyState === WebSocket.CONNECTING)
|
|
) {
|
|
return;
|
|
}
|
|
this.isConnecting = true;
|
|
const ws = new WebSocket(url, subprotocols || []);
|
|
this.socket = ws;
|
|
ws.onmessage = (event: MessageEvent) => {
|
|
this.listeners.forEach((listener) => {
|
|
try {
|
|
listener(event);
|
|
} catch (err) {
|
|
console.error("UserEvents listener error", err);
|
|
}
|
|
});
|
|
};
|
|
ws.onopen = () => {
|
|
if (this.socket === ws) this.isConnecting = false;
|
|
};
|
|
ws.onclose = () => {
|
|
if (this.socket === ws) {
|
|
this.socket = null;
|
|
this.isConnecting = false;
|
|
}
|
|
};
|
|
ws.onerror = () => {
|
|
if (this.socket === ws) this.isConnecting = false;
|
|
};
|
|
}
|
|
|
|
subscribe(listener: (event: MessageEvent) => void): () => void {
|
|
this.listeners.add(listener);
|
|
if (this.closeTimeoutId !== null) {
|
|
clearTimeout(this.closeTimeoutId);
|
|
this.closeTimeoutId = null;
|
|
}
|
|
return () => {
|
|
this.listeners.delete(listener);
|
|
if (this.listeners.size === 0) {
|
|
this.closeTimeoutId = window.setTimeout(() => {
|
|
if (this.socket) {
|
|
try {
|
|
this.socket.close();
|
|
} catch (err) {
|
|
console.warn("Error closing user events socket", err);
|
|
}
|
|
}
|
|
this.socket = null;
|
|
this.closeTimeoutId = null;
|
|
}, 1000);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
const sharedStore = new UserEventsStore();
|
|
|
|
export function UserEventsProvider({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const auth = useAuth();
|
|
const queryClient = useQueryClient();
|
|
const tokenRef = useRef<string | null>(null);
|
|
const detachRef = useRef<(() => void) | null>(null);
|
|
|
|
useEffect(() => {
|
|
// Only tear down when the user is truly unauthenticated
|
|
if (auth.status === "unauthenticated") {
|
|
if (detachRef.current) {
|
|
try {
|
|
detachRef.current();
|
|
} catch (err) {
|
|
console.warn("Error detaching UserEvents listener", err);
|
|
}
|
|
detachRef.current = null;
|
|
}
|
|
tokenRef.current = null;
|
|
return;
|
|
}
|
|
|
|
// During loading/refreshing, keep the existing connection intact
|
|
if (auth.status !== "authenticated") {
|
|
return;
|
|
}
|
|
|
|
// Authenticated: pin the initial token for the lifetime of this WS connection
|
|
if (!tokenRef.current && auth.accessToken) {
|
|
tokenRef.current = auth.accessToken;
|
|
}
|
|
const pinnedToken = tokenRef.current;
|
|
const url = `${WEBSOCKET_URL}/v1/events`;
|
|
|
|
// Ensure a single shared connection
|
|
sharedStore.ensureConnection(
|
|
url,
|
|
pinnedToken ? ["bearer", pinnedToken] : undefined,
|
|
);
|
|
|
|
// Subscribe once; avoid re-subscribing during transient status changes
|
|
if (!detachRef.current) {
|
|
const onMessage = (event: MessageEvent) => {
|
|
try {
|
|
const msg = UserEvent.parse(JSON.parse(event.data));
|
|
const eventName = msg.event;
|
|
|
|
const invalidateList = () => invalidateTranscriptLists(queryClient);
|
|
|
|
switch (eventName) {
|
|
case "TRANSCRIPT_CREATED":
|
|
case "TRANSCRIPT_DELETED":
|
|
case "TRANSCRIPT_STATUS":
|
|
case "TRANSCRIPT_FINAL_TITLE":
|
|
case "TRANSCRIPT_DURATION":
|
|
invalidateList().then(() => {});
|
|
break;
|
|
|
|
default:
|
|
// Ignore other content events for list updates
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
console.warn("Invalid user event message", event.data);
|
|
}
|
|
};
|
|
|
|
const unsubscribe = sharedStore.subscribe(onMessage);
|
|
detachRef.current = unsubscribe;
|
|
}
|
|
}, [auth.status, queryClient]);
|
|
|
|
// On unmount, detach the listener and clear the pinned token
|
|
useEffect(() => {
|
|
return () => {
|
|
if (detachRef.current) {
|
|
try {
|
|
detachRef.current();
|
|
} catch (err) {
|
|
console.warn("Error detaching UserEvents listener on unmount", err);
|
|
}
|
|
detachRef.current = null;
|
|
}
|
|
tokenRef.current = null;
|
|
};
|
|
}, []);
|
|
|
|
return <>{children}</>;
|
|
}
|