Refactor monolithic modules and harden booking, email, and auth infrastructure.
Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,15 +5,26 @@ import { eq, and } from 'drizzle-orm';
|
||||
import { getNow } from '../lib/utils.js';
|
||||
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
|
||||
import emailService from '../lib/email.js';
|
||||
import { getPubSub } from '../lib/stores/pubsub.js';
|
||||
import { getLock } from '../lib/stores/lock.js';
|
||||
|
||||
const lnbitsRouter = new Hono();
|
||||
|
||||
// Store for active SSE connections (ticketId -> Set of response writers)
|
||||
// Local SSE connections owned by THIS process (ticketId -> Set of response writers).
|
||||
// Cross-instance delivery is handled by pub/sub: see paymentChannel below.
|
||||
const activeConnections = new Map<string, Set<(data: any) => Promise<void>>>();
|
||||
|
||||
// Pub/sub unsubscribe handles per ticket (one local subscription per ticket).
|
||||
const channelUnsubs = new Map<string, () => void>();
|
||||
|
||||
// Store for active background checkers (ticketId -> intervalId)
|
||||
const activeCheckers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
/** Pub/sub channel that carries payment events for a ticket. */
|
||||
function paymentChannel(ticketId: string): string {
|
||||
return `payment:${ticketId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* LNbits webhook payload structure
|
||||
*/
|
||||
@@ -32,9 +43,21 @@ interface LNbitsWebhookPayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all connected clients for a ticket
|
||||
* Notify every client for a ticket across all instances.
|
||||
*
|
||||
* Publishes to the ticket's pub/sub channel. In single-instance / in-memory
|
||||
* mode this is an in-process broadcast; with Redis it reaches whichever
|
||||
* instance(s) actually hold the SSE socket(s) for this ticket.
|
||||
*/
|
||||
async function notifyClients(ticketId: string, data: any) {
|
||||
await getPubSub().publish(paymentChannel(ticketId), data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an event to the SSE sockets held by THIS process for a ticket.
|
||||
* Invoked by the pub/sub subscription handler.
|
||||
*/
|
||||
async function deliverLocal(ticketId: string, data: any) {
|
||||
const connections = activeConnections.get(ticketId);
|
||||
if (connections) {
|
||||
await Promise.all(
|
||||
@@ -49,17 +72,42 @@ async function notifyClients(ticketId: string, data: any) {
|
||||
}
|
||||
}
|
||||
|
||||
// Distributed lock tokens for the per-ticket poller (ticketId -> token).
|
||||
const checkerLockTokens = new Map<string, string>();
|
||||
|
||||
/** Release the per-ticket poller lock if this process holds it. */
|
||||
function releaseCheckerLock(ticketId: string) {
|
||||
const token = checkerLockTokens.get(ticketId);
|
||||
if (token) {
|
||||
checkerLockTokens.delete(ticketId);
|
||||
void getLock().release(`checker:${ticketId}`, token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background payment checking for a ticket
|
||||
* Start background payment checking for a ticket.
|
||||
*
|
||||
* Only one instance should poll LNbits per ticket, so we take a distributed
|
||||
* lock for the lifetime of the poll. Other instances skip polling and instead
|
||||
* receive the result via pub/sub. With no Redis configured the lock is a local
|
||||
* no-op and behavior matches the original single-instance polling.
|
||||
*/
|
||||
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
|
||||
// Don't start if already checking
|
||||
async function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
|
||||
// Don't start if already checking on this instance
|
||||
if (activeCheckers.has(ticketId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const expiryMs = expirySeconds * 1000;
|
||||
|
||||
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
|
||||
if (!lockToken) {
|
||||
// Another instance is already polling this ticket.
|
||||
return;
|
||||
}
|
||||
checkerLockTokens.set(ticketId, lockToken);
|
||||
|
||||
const startTime = Date.now();
|
||||
let checkCount = 0;
|
||||
|
||||
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
|
||||
@@ -73,6 +121,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
|
||||
console.log(`Invoice expired for ticket ${ticketId}`);
|
||||
clearInterval(checkInterval);
|
||||
activeCheckers.delete(ticketId);
|
||||
releaseCheckerLock(ticketId);
|
||||
await notifyClients(ticketId, { type: 'expired', ticketId });
|
||||
return;
|
||||
}
|
||||
@@ -84,6 +133,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
|
||||
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
|
||||
clearInterval(checkInterval);
|
||||
activeCheckers.delete(ticketId);
|
||||
releaseCheckerLock(ticketId);
|
||||
|
||||
await handlePaymentComplete(ticketId, paymentHash);
|
||||
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
|
||||
@@ -104,6 +154,7 @@ function stopBackgroundChecker(ticketId: string) {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
activeCheckers.delete(ticketId);
|
||||
releaseCheckerLock(ticketId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +324,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
|
||||
// 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
|
||||
await startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
|
||||
}
|
||||
|
||||
// Prevent proxies/CDNs from buffering the event stream so events flush immediately.
|
||||
@@ -291,9 +342,15 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register this connection
|
||||
// Register this connection. The first local connection for a ticket also
|
||||
// subscribes to the ticket's pub/sub channel so events published by any
|
||||
// instance (webhook or background checker) are delivered to these sockets.
|
||||
if (!activeConnections.has(ticketId)) {
|
||||
activeConnections.set(ticketId, new Set());
|
||||
const unsub = await getPubSub().subscribe(paymentChannel(ticketId), (data) => {
|
||||
void deliverLocal(ticketId, data);
|
||||
});
|
||||
channelUnsubs.set(ticketId, unsub);
|
||||
}
|
||||
activeConnections.get(ticketId)!.add(sendEvent);
|
||||
|
||||
@@ -317,6 +374,12 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
|
||||
connections.delete(sendEvent);
|
||||
if (connections.size === 0) {
|
||||
activeConnections.delete(ticketId);
|
||||
// Drop the pub/sub subscription once no local sockets remain.
|
||||
const unsub = channelUnsubs.get(ticketId);
|
||||
if (unsub) {
|
||||
unsub();
|
||||
channelUnsubs.delete(ticketId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user