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:
etiennecollin
2025-09-07 01:46:07 -04:00
parent 4dddbe9b70
commit 562a314fc8
8 changed files with 173 additions and 39 deletions

View 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",
},
});
}

View File

@@ -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(() => {

View 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;
}

View File

@@ -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") || "";

View File

@@ -0,0 +1,10 @@
"use server";
import { sseManager } from "./sseManager";
export async function notifyVouchersUpdated() {
sseManager.broadcastToClients({
type: "vouchersUpdated",
timestamp: Date.now(),
});
}

View File

@@ -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;
},
};

View File

@@ -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 },

View 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;