mirror of
https://github.com/etiennecollin/unifi-voucher-manager.git
synced 2025-10-23 00:02:10 +00:00
fix: updates work accross clients
Fixed issue where custom window events only worked within single browser tabs. Implemented SSE with singleton pattern to broadcast updates across all open tabs and users when voucher operations occur. - Added SSE manager with globalThis singleton for cross-process persistence - Created server action to broadcast events via SSE after voucher operations - Updated client API calls to trigger server action notifications - SSE automatically triggers existing vouchersUpdated window events
This commit is contained in:
37
frontend/src/app/api/events/route.ts
Normal file
37
frontend/src/app/api/events/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { sseManager } from "@/utils/sseManager";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const clientId = randomUUID();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
sseManager.addClient(clientId, controller);
|
||||
|
||||
// Send initial connection message
|
||||
const welcomeMessage = `data: ${JSON.stringify({
|
||||
type: "connected",
|
||||
clientId: clientId,
|
||||
})}\n\n`;
|
||||
|
||||
controller.enqueue(welcomeMessage);
|
||||
|
||||
// Clean up when connection closes
|
||||
request.signal.addEventListener("abort", () => {
|
||||
sseManager.removeClient(clientId);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
sseManager.removeClient(clientId);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Theme } from "@/components/utils/ThemeSwitcher";
|
||||
import { useServerEvents } from "@/hooks/useServerEvents";
|
||||
import {
|
||||
generateWifiConfig,
|
||||
generateWiFiQRString,
|
||||
@@ -22,8 +23,8 @@ export const GlobalProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
}) => {
|
||||
const [wifiConfig, setWifiConfig] = useState<WifiConfig | null>(null);
|
||||
const [wifiString, setWifiString] = useState<string | null>(null);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>("system");
|
||||
useServerEvents();
|
||||
|
||||
// WiFi setup
|
||||
useEffect(() => {
|
||||
|
43
frontend/src/hooks/useServerEvents.ts
Normal file
43
frontend/src/hooks/useServerEvents.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useServerEvents() {
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Setting up SSE connection...");
|
||||
|
||||
eventSourceRef.current = new EventSource("/api/events");
|
||||
|
||||
eventSourceRef.current.onopen = () => {
|
||||
console.log("SSE connection opened");
|
||||
};
|
||||
|
||||
eventSourceRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
switch (data.type) {
|
||||
case "connected":
|
||||
console.log(`SSE connected with clientId: ${data.clientId}`);
|
||||
break;
|
||||
case "vouchersUpdated":
|
||||
window.dispatchEvent(new CustomEvent("vouchersUpdated"));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing SSE data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSourceRef.current.onerror = (error) => {
|
||||
console.error("SSE connection error:", error);
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSourceRef.current?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return eventSourceRef.current;
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
|
||||
export const config = {
|
||||
matcher: "/api/:path*",
|
||||
matcher: "/rust-api/:path*",
|
||||
};
|
||||
|
||||
const DEFAULT_FRONTEND_TO_BACKEND_URL = "http://127.0.0.1";
|
||||
@@ -10,11 +10,19 @@ const DEFAULT_BACKEND_BIND_PORT = "8080";
|
||||
const IPV6_IPV4_MAPPED_PREFIX = "::ffff:";
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const backendUrl = new URL(
|
||||
`${process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL}:${process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT}${request.nextUrl.pathname}${request.nextUrl.search}`,
|
||||
// Remove the /rust-api prefix and reconstruct the path for the backend
|
||||
const backendPath = request.nextUrl.pathname.replace(/^\/rust-api/, "/api");
|
||||
|
||||
const backendUrl =
|
||||
process.env.FRONTEND_TO_BACKEND_URL || DEFAULT_FRONTEND_TO_BACKEND_URL;
|
||||
const backendPort =
|
||||
process.env.BACKEND_BIND_PORT || DEFAULT_BACKEND_BIND_PORT;
|
||||
|
||||
const backendFullUrl = new URL(
|
||||
`${backendUrl}:${backendPort}${backendPath}${request.nextUrl.search}`,
|
||||
);
|
||||
|
||||
const response = NextResponse.rewrite(backendUrl, { request });
|
||||
const response = NextResponse.rewrite(backendFullUrl, { request });
|
||||
|
||||
// Forward the real client IP
|
||||
let clientIp = request.headers.get("x-forwarded-for") || "";
|
||||
|
10
frontend/src/utils/actions.ts
Normal file
10
frontend/src/utils/actions.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { sseManager } from "./sseManager";
|
||||
|
||||
export async function notifyVouchersUpdated() {
|
||||
sseManager.broadcastToClients({
|
||||
type: "vouchersUpdated",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
@@ -4,6 +4,7 @@ import {
|
||||
VoucherCreatedResponse,
|
||||
VoucherDeletedResponse,
|
||||
} from "@/types/voucher";
|
||||
import { notifyVouchersUpdated } from "./actions";
|
||||
|
||||
function removeNullUndefined<T extends Record<string, any>>(obj: T): T {
|
||||
return Object.fromEntries(
|
||||
@@ -14,7 +15,7 @@ function removeNullUndefined<T extends Record<string, any>>(obj: T): T {
|
||||
}
|
||||
|
||||
async function call<T>(endpoint: string, opts: RequestInit = {}) {
|
||||
const res = await fetch(`/api/${endpoint}`, {
|
||||
const res = await fetch(`/rust-api/${endpoint}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
@@ -26,10 +27,6 @@ async function call<T>(endpoint: string, opts: RequestInit = {}) {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function notifyVouchersUpdated() {
|
||||
window.dispatchEvent(new CustomEvent("vouchersUpdated"));
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getAllVouchers: () => call<{ data: Voucher[] }>("/vouchers"),
|
||||
|
||||
@@ -46,7 +43,7 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(filteredData),
|
||||
});
|
||||
notifyVouchersUpdated();
|
||||
await notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -54,7 +51,7 @@ export const api = {
|
||||
const result = await call<Voucher>("/vouchers/rolling", {
|
||||
method: "POST",
|
||||
});
|
||||
notifyVouchersUpdated();
|
||||
await notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -62,7 +59,7 @@ export const api = {
|
||||
const result = await call<VoucherDeletedResponse>("/vouchers/expired", {
|
||||
method: "DELETE",
|
||||
});
|
||||
notifyVouchersUpdated();
|
||||
await notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -73,7 +70,7 @@ export const api = {
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
notifyVouchersUpdated();
|
||||
await notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
|
||||
@@ -85,7 +82,7 @@ export const api = {
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
notifyVouchersUpdated();
|
||||
await notifyVouchersUpdated();
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
@@ -6,35 +6,12 @@ export interface NotificationPayload {
|
||||
type: NotificationType;
|
||||
}
|
||||
|
||||
/** Generate a RFC-4122 v4 UUID */
|
||||
function generateUUID(): string {
|
||||
// use crypto.randomUUID() when available
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
// @ts-ignore
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// fallback to crypto.getRandomValues
|
||||
let d = new Date().getTime();
|
||||
let d2 = (performance && performance.now && performance.now() * 1000) || 0;
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r =
|
||||
Math.random() * 16 +
|
||||
// use high-res entropy if available
|
||||
(crypto && crypto.getRandomValues
|
||||
? crypto.getRandomValues(new Uint8Array(1))[0]
|
||||
: 0);
|
||||
const v = c === "x" ? r % 16 | 0 : (r % 16 & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a notification event. Listeners (e.g. NotificationContainer)
|
||||
* will pick this up and render it.
|
||||
*/
|
||||
export function notify(message: string, type: NotificationType = "info") {
|
||||
const id = generateUUID();
|
||||
const id = crypto.randomUUID();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<NotificationPayload>("notify", {
|
||||
detail: { id, message, type },
|
||||
|
61
frontend/src/utils/sseManager.ts
Normal file
61
frontend/src/utils/sseManager.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
class SSEManager {
|
||||
private clients: Map<string, ReadableStreamDefaultController> = new Map();
|
||||
|
||||
addClient(id: string, controller: ReadableStreamDefaultController) {
|
||||
this.clients.set(id, controller);
|
||||
console.log(`Client ${id} added. Total clients: ${this.clients.size}`);
|
||||
}
|
||||
|
||||
removeClient(id: string) {
|
||||
const removed = this.clients.delete(id);
|
||||
console.log(
|
||||
`Client ${id} removed: ${removed}. Total clients: ${this.clients.size}`,
|
||||
);
|
||||
}
|
||||
|
||||
broadcastToClients(data: any) {
|
||||
const message = `data: ${JSON.stringify(data)}\n\n`;
|
||||
|
||||
const clientsToRemove: string[] = [];
|
||||
|
||||
this.clients.forEach((controller, id) => {
|
||||
try {
|
||||
controller.enqueue(message);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error sending message to client ${id}, marking for removal:`,
|
||||
error,
|
||||
);
|
||||
clientsToRemove.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up dead connections
|
||||
clientsToRemove.forEach((id) => this.clients.delete(id));
|
||||
|
||||
if (clientsToRemove.length > 0) {
|
||||
console.log(
|
||||
`Removed ${clientsToRemove.length} dead connections. Remaining: ${this.clients.size}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getClientCount() {
|
||||
return this.clients.size;
|
||||
}
|
||||
|
||||
getAllClientIds() {
|
||||
return Array.from(this.clients.keys());
|
||||
}
|
||||
}
|
||||
|
||||
// Create a global singleton instance
|
||||
const globalForSSE = globalThis as unknown as {
|
||||
sseManager: SSEManager | undefined;
|
||||
};
|
||||
|
||||
// If the instance exists, use it. Otherwise, create a new one.
|
||||
export const sseManager = globalForSSE.sseManager ?? new SSEManager();
|
||||
|
||||
// Update the global reference
|
||||
globalForSSE.sseManager = sseManager;
|
Reference in New Issue
Block a user