diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index ce29569..9511c1a 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -9,7 +9,7 @@ import emailService from '../lib/email.js'; const lnbitsRouter = new Hono(); // Store for active SSE connections (ticketId -> Set of response writers) -const activeConnections = new Map void>>(); +const activeConnections = new Map Promise>>(); // Store for active background checkers (ticketId -> intervalId) const activeCheckers = new Map(); @@ -34,16 +34,18 @@ interface LNbitsWebhookPayload { /** * Notify all connected clients for a ticket */ -function notifyClients(ticketId: string, data: any) { +async function notifyClients(ticketId: string, data: any) { const connections = activeConnections.get(ticketId); if (connections) { - connections.forEach(send => { - try { - send(data); - } catch (e) { - // Connection might be closed - } - }); + await Promise.all( + Array.from(connections).map(async (send) => { + try { + await send(data); + } catch (e) { + // Connection might be closed + } + }) + ); } } @@ -71,7 +73,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec console.log(`Invoice expired for ticket ${ticketId}`); clearInterval(checkInterval); activeCheckers.delete(ticketId); - notifyClients(ticketId, { type: 'expired', ticketId }); + await notifyClients(ticketId, { type: 'expired', ticketId }); return; } @@ -84,7 +86,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec activeCheckers.delete(ticketId); await handlePaymentComplete(ticketId, paymentHash); - notifyClients(ticketId, { type: 'paid', ticketId, paymentHash }); + await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash }); } } catch (error) { console.error(`Error checking payment for ticket ${ticketId}:`, error); @@ -165,7 +167,7 @@ lnbitsRouter.post('/webhook', async (c) => { await handlePaymentComplete(ticketId, payload.payment_hash); // Notify connected clients via SSE - notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash }); + await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash }); return c.json({ received: true, processed: true }, 200); } catch (error) { @@ -264,38 +266,39 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { return c.json({ error: 'Ticket not found' }, 404); } - // If already paid, return immediately - if (ticket.status === 'confirmed') { - return c.json({ type: 'already_paid', ticketId }, 200); - } - // Get payment to start background checker const payment = await dbGet( (db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId)) ); - // Start background checker if not already running - if (payment?.reference && !activeCheckers.has(ticketId)) { + // Start background checker if not already running (only while still pending) + if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) { startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry } + // Prevent proxies/CDNs from buffering the event stream so events flush immediately. + c.header('Cache-Control', 'no-cache, no-transform'); + c.header('X-Accel-Buffering', 'no'); + return streamSSE(c, async (stream) => { + const sendEvent = async (data: any) => { + await stream.writeSSE({ data: JSON.stringify(data), event: 'payment' }); + }; + + // If already paid, notify over SSE and close (EventSource can parse this). + if (ticket.status === 'confirmed') { + await sendEvent({ type: 'already_paid', ticketId }); + return; + } + // Register this connection if (!activeConnections.has(ticketId)) { activeConnections.set(ticketId, new Set()); } - - const sendEvent = (data: any) => { - stream.writeSSE({ data: JSON.stringify(data), event: 'payment' }); - }; - activeConnections.get(ticketId)!.add(sendEvent); // Send initial status - await stream.writeSSE({ - data: JSON.stringify({ type: 'connected', ticketId }), - event: 'payment' - }); + await sendEvent({ type: 'connected', ticketId }); // Keep connection alive with heartbeat const heartbeat = setInterval(async () => { diff --git a/deploy/front-end_nginx.conf b/deploy/front-end_nginx.conf index 7b557d0..30b7c88 100644 --- a/deploy/front-end_nginx.conf +++ b/deploy/front-end_nginx.conf @@ -43,6 +43,25 @@ server { access_log /var/log/nginx/spanglish_frontend_access.log; error_log /var/log/nginx/spanglish_frontend_error.log; + # LNbits payment SSE stream - must not be buffered or events won't flush in + # real time. Regex location takes precedence over the /api prefix below. + location ~ ^/api/lnbits/stream/ { + proxy_pass http://spanglish_backend; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ''; + + # Disable buffering/caching for Server-Sent Events + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_connect_timeout 300s; + } + # Proxy /api to backend location /api { proxy_pass http://spanglish_backend; diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 5d2d909..ed20683 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -299,71 +299,75 @@ export default function BookingPage() { ); }; - // Connect to SSE for real-time payment updates - const connectPaymentStream = (ticketId: string) => { - const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; - const eventSource = new EventSource(`${apiUrl}/api/lnbits/stream/${ticketId}`); - + // Watch for Lightning payment confirmation while on the paying step. + // SSE gives instant updates; a 3s poll runs in parallel as a safety net so a + // buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI. + useEffect(() => { + if (step !== 'paying' || !bookingResult?.ticketId) return; + + const ticketId = bookingResult.ticketId; + let settled = false; + let pollTimer: ReturnType | null = null; + + const confirmPaid = () => { + if (settled) return; + settled = true; + toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!'); + setPaymentPending(false); + setStep('success'); + }; + + const expire = () => { + if (settled) return; + settled = true; + toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired'); + setPaymentPending(false); + }; + + // Always same-origin so the streaming proxy route handler is used (it + // bypasses the rewrite, which buffers SSE). + const eventSource = new EventSource(`/api/lnbits/stream/${ticketId}`); + eventSource.addEventListener('payment', (event) => { try { - const data = JSON.parse(event.data); - console.log('Payment event:', data); - - if (data.type === 'paid') { - toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!'); - setPaymentPending(false); - setStep('success'); - eventSource.close(); + const data = JSON.parse((event as MessageEvent).data); + if (data.type === 'paid' || data.type === 'already_paid') { + confirmPaid(); } else if (data.type === 'expired') { - toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired'); - setPaymentPending(false); - eventSource.close(); - } else if (data.type === 'already_paid') { - setPaymentPending(false); - setStep('success'); - eventSource.close(); + expire(); } } catch (e) { console.error('Error parsing payment event:', e); } }); - - eventSource.onerror = (error) => { - console.error('SSE error:', error); - // Fallback to polling if SSE fails + + eventSource.onerror = () => { + // SSE failed or was closed; the poll below remains the source of truth. eventSource.close(); - fallbackPoll(ticketId); }; - - return eventSource; - }; - - // Fallback polling if SSE is not available - const fallbackPoll = async (ticketId: string) => { - const maxAttempts = 60; - let attempts = 0; - + const poll = async () => { - attempts++; try { const status = await ticketsApi.checkPaymentStatus(ticketId); if (status.isPaid) { - toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!'); - setPaymentPending(false); - setStep('success'); + confirmPaid(); return; } } catch (error) { console.error('Error checking payment status:', error); } - - if (attempts < maxAttempts && paymentPending) { - setTimeout(poll, 5000); + if (!settled) { + pollTimer = setTimeout(poll, 3000); } }; - - poll(); - }; + pollTimer = setTimeout(poll, 3000); + + return () => { + settled = true; + eventSource.close(); + if (pollTimer) clearTimeout(pollTimer); + }; + }, [step, bookingResult?.ticketId, locale]); // Copy invoice to clipboard const copyInvoiceToClipboard = (invoice: string) => { @@ -464,9 +468,7 @@ export default function BookingPage() { setBookingResult(result); setStep('paying'); setPaymentPending(true); - - // Connect to SSE for real-time payment updates - connectPaymentStream(primaryTicket.id); + // Payment confirmation is handled by the paying-step watcher effect. } else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') { // Manual payment methods - show payment details setBookingResult({ diff --git a/frontend/src/app/api/lnbits/stream/[ticketId]/route.ts b/frontend/src/app/api/lnbits/stream/[ticketId]/route.ts new file mode 100644 index 0000000..ea4a9a4 --- /dev/null +++ b/frontend/src/app/api/lnbits/stream/[ticketId]/route.ts @@ -0,0 +1,44 @@ +// Streaming proxy for the backend LNbits SSE endpoint. +// +// Next.js `rewrites` buffer `text/event-stream` responses, so payment events +// never reach the browser in real time when going through the same-origin proxy. +// This route handler streams the backend response body straight through with +// anti-buffering headers, so EventSource on the booking page works in dev and +// proxyless deployments. A route handler takes precedence over the rewrite. + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; + +export async function GET( + request: Request, + { params }: { params: { ticketId: string } } +) { + const upstream = await fetch( + `${BACKEND_URL}/api/lnbits/stream/${params.ticketId}`, + { + headers: { Accept: 'text/event-stream' }, + signal: request.signal, + } + ); + + // If the ticket is already confirmed the backend may answer with JSON instead + // of a stream; pass that through unchanged so the client can handle it. + const contentType = upstream.headers.get('content-type') || ''; + if (!contentType.includes('text/event-stream')) { + return new Response(upstream.body, { + status: upstream.status, + headers: { 'Content-Type': contentType || 'application/json' }, + }); + } + + return new Response(upstream.body, { + status: upstream.status, + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +}