diff --git a/frontend/src/app/api/events/route.ts b/frontend/src/app/api/events/route.ts new file mode 100644 index 0000000..72423d5 --- /dev/null +++ b/frontend/src/app/api/events/route.ts @@ -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", + }, + }); +} diff --git a/frontend/src/contexts/GlobalContext.tsx b/frontend/src/contexts/GlobalContext.tsx index c394edb..61bbff9 100644 --- a/frontend/src/contexts/GlobalContext.tsx +++ b/frontend/src/contexts/GlobalContext.tsx @@ -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(null); const [wifiString, setWifiString] = useState(null); - const [theme, setTheme] = useState("system"); + useServerEvents(); // WiFi setup useEffect(() => { diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts new file mode 100644 index 0000000..e0eabb7 --- /dev/null +++ b/frontend/src/hooks/useServerEvents.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from "react"; + +export function useServerEvents() { + const eventSourceRef = useRef(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; +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 7f61fd9..2ca837d 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -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") || ""; diff --git a/frontend/src/utils/actions.ts b/frontend/src/utils/actions.ts new file mode 100644 index 0000000..2fcdb14 --- /dev/null +++ b/frontend/src/utils/actions.ts @@ -0,0 +1,10 @@ +"use server"; + +import { sseManager } from "./sseManager"; + +export async function notifyVouchersUpdated() { + sseManager.broadcastToClients({ + type: "vouchersUpdated", + timestamp: Date.now(), + }); +} diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index e7b7dca..0ce7dfb 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -4,6 +4,7 @@ import { VoucherCreatedResponse, VoucherDeletedResponse, } from "@/types/voucher"; +import { notifyVouchersUpdated } from "./actions"; function removeNullUndefined>(obj: T): T { return Object.fromEntries( @@ -14,7 +15,7 @@ function removeNullUndefined>(obj: T): T { } async function call(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(endpoint: string, opts: RequestInit = {}) { return res.json() as Promise; } -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("/vouchers/rolling", { method: "POST", }); - notifyVouchersUpdated(); + await notifyVouchersUpdated(); return result; }, @@ -62,7 +59,7 @@ export const api = { const result = await call("/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; }, }; diff --git a/frontend/src/utils/notifications.ts b/frontend/src/utils/notifications.ts index ccaddf0..2db6969 100644 --- a/frontend/src/utils/notifications.ts +++ b/frontend/src/utils/notifications.ts @@ -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("notify", { detail: { id, message, type }, diff --git a/frontend/src/utils/sseManager.ts b/frontend/src/utils/sseManager.ts new file mode 100644 index 0000000..c634f6d --- /dev/null +++ b/frontend/src/utils/sseManager.ts @@ -0,0 +1,61 @@ +class SSEManager { + private clients: Map = 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;