|
|
@@ -0,0 +1,133 @@
|
|
|
+"use client";
|
|
|
+
|
|
|
+import { useEffect, useRef, useState } from "react";
|
|
|
+import type { RealtimeChannel, RealtimePostgresChangesPayload } from "@supabase/supabase-js";
|
|
|
+import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
|
|
+import {
|
|
|
+ type ConnectionStatus,
|
|
|
+ ReconnectionManager,
|
|
|
+ cleanupChannel,
|
|
|
+} from "@/lib/realtime/subscription-manager";
|
|
|
+
|
|
|
+interface PostgresChangesConfig<T extends Record<string, unknown>> {
|
|
|
+ event: "INSERT" | "UPDATE" | "DELETE" | "*";
|
|
|
+ schema: string;
|
|
|
+ table: string;
|
|
|
+ filter?: string;
|
|
|
+ onPayload: (payload: RealtimePostgresChangesPayload<T>) => void;
|
|
|
+}
|
|
|
+
|
|
|
+interface UseRealtimeChannelOptions<T extends Record<string, unknown>> {
|
|
|
+ /** Unique channel name. Changing this unsubscribes from the old channel and subscribes to the new one. */
|
|
|
+ channelName: string | null;
|
|
|
+ /** Postgres changes subscription config. */
|
|
|
+ config: PostgresChangesConfig<T>;
|
|
|
+ /** Whether the subscription is enabled. Defaults to true. */
|
|
|
+ enabled?: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+interface UseRealtimeChannelReturn {
|
|
|
+ status: ConnectionStatus;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Generic hook for Supabase Realtime channel management.
|
|
|
+ * Handles subscribe/unsubscribe lifecycle, connection state tracking,
|
|
|
+ * and reconnection with exponential backoff.
|
|
|
+ */
|
|
|
+export function useRealtimeChannel<T extends Record<string, unknown>>({
|
|
|
+ channelName,
|
|
|
+ config,
|
|
|
+ enabled = true,
|
|
|
+}: UseRealtimeChannelOptions<T>): UseRealtimeChannelReturn {
|
|
|
+ const [status, setStatus] = useState<ConnectionStatus>("disconnected");
|
|
|
+ const channelRef = useRef<RealtimeChannel | null>(null);
|
|
|
+ const reconnectRef = useRef(new ReconnectionManager());
|
|
|
+ const onPayloadRef = useRef(config.onPayload);
|
|
|
+ const configRef = useRef(config);
|
|
|
+
|
|
|
+ // Sync refs inside an effect to satisfy react-hooks/refs (React 19 strict mode)
|
|
|
+ useEffect(() => {
|
|
|
+ onPayloadRef.current = config.onPayload;
|
|
|
+ configRef.current = config;
|
|
|
+ });
|
|
|
+
|
|
|
+ // Reset status when subscription is disabled
|
|
|
+ const isActive = !!channelName && enabled;
|
|
|
+ if (!isActive && status !== "disconnected") {
|
|
|
+ setStatus("disconnected");
|
|
|
+ }
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!channelName || !enabled) {
|
|
|
+ if (channelRef.current) {
|
|
|
+ const supabase = getSupabaseBrowserClient();
|
|
|
+ cleanupChannel(channelRef.current, (ch) => supabase.removeChannel(ch));
|
|
|
+ channelRef.current = null;
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const reconnect = reconnectRef.current;
|
|
|
+
|
|
|
+ // Capture non-null value for use in the subscribe closure
|
|
|
+ const activeChannelName = channelName;
|
|
|
+
|
|
|
+ function subscribe(): void {
|
|
|
+ const supabase = getSupabaseBrowserClient();
|
|
|
+ const cfg = configRef.current;
|
|
|
+
|
|
|
+ if (channelRef.current) {
|
|
|
+ cleanupChannel(channelRef.current, (ch) => supabase.removeChannel(ch));
|
|
|
+ channelRef.current = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ setStatus("connecting");
|
|
|
+
|
|
|
+ const channel = supabase
|
|
|
+ .channel(activeChannelName)
|
|
|
+ .on<T>(
|
|
|
+ "postgres_changes",
|
|
|
+ {
|
|
|
+ event: cfg.event,
|
|
|
+ schema: cfg.schema,
|
|
|
+ table: cfg.table,
|
|
|
+ ...(cfg.filter ? { filter: cfg.filter } : {}),
|
|
|
+ },
|
|
|
+ (payload) => {
|
|
|
+ onPayloadRef.current(payload);
|
|
|
+ },
|
|
|
+ )
|
|
|
+ .subscribe((subscribedStatus) => {
|
|
|
+ if (subscribedStatus === "SUBSCRIBED") {
|
|
|
+ setStatus("connected");
|
|
|
+ reconnect.reset();
|
|
|
+ } else if (
|
|
|
+ subscribedStatus === "CHANNEL_ERROR" ||
|
|
|
+ subscribedStatus === "TIMED_OUT"
|
|
|
+ ) {
|
|
|
+ setStatus("error");
|
|
|
+ reconnect.schedule(() => subscribe());
|
|
|
+ } else if (subscribedStatus === "CLOSED") {
|
|
|
+ setStatus("disconnected");
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ channelRef.current = channel;
|
|
|
+ }
|
|
|
+
|
|
|
+ subscribe();
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ reconnect.clear();
|
|
|
+ if (channelRef.current) {
|
|
|
+ const supabase = getSupabaseBrowserClient();
|
|
|
+ cleanupChannel(channelRef.current, (ch) => supabase.removeChannel(ch));
|
|
|
+ channelRef.current = null;
|
|
|
+ }
|
|
|
+ setStatus("disconnected");
|
|
|
+ };
|
|
|
+ }, [channelName, enabled]);
|
|
|
+
|
|
|
+ return { status };
|
|
|
+}
|