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>
122 lines
3.5 KiB
TypeScript
122 lines
3.5 KiB
TypeScript
// Pub/Sub abstraction with two implementations:
|
|
// - memory: in-process EventEmitter (single instance only)
|
|
// - redis: PUBLISH / SUBSCRIBE so a message published on one instance reaches
|
|
// subscribers on every instance
|
|
//
|
|
// Messages are JSON-serialized. Selection happens once based on REDIS_URL.
|
|
|
|
import { EventEmitter } from 'events';
|
|
import { getRedis, getSubscriber, isRedisEnabled } from '../redis.js';
|
|
|
|
export type PubSubHandler = (message: any) => void;
|
|
|
|
export interface PubSub {
|
|
readonly backend: 'memory' | 'redis';
|
|
publish(channel: string, message: any): Promise<void>;
|
|
// Returns an unsubscribe function for this specific handler.
|
|
subscribe(channel: string, handler: PubSubHandler): Promise<() => void>;
|
|
}
|
|
|
|
// ==================== Memory implementation ====================
|
|
|
|
class MemoryPubSub implements PubSub {
|
|
readonly backend = 'memory' as const;
|
|
private emitter = new EventEmitter();
|
|
|
|
constructor() {
|
|
// SSE fan-out can attach many listeners to the same channel; lift the cap.
|
|
this.emitter.setMaxListeners(0);
|
|
}
|
|
|
|
async publish(channel: string, message: any): Promise<void> {
|
|
this.emitter.emit(channel, message);
|
|
}
|
|
|
|
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
|
|
this.emitter.on(channel, handler);
|
|
return () => this.emitter.off(channel, handler);
|
|
}
|
|
}
|
|
|
|
// ==================== Redis implementation ====================
|
|
|
|
class RedisPubSub implements PubSub {
|
|
readonly backend = 'redis' as const;
|
|
// Per-channel handler sets so a single Redis subscription fans out locally.
|
|
private handlers = new Map<string, Set<PubSubHandler>>();
|
|
private wired = false;
|
|
|
|
private ensureWired(): void {
|
|
if (this.wired) return;
|
|
const sub = getSubscriber();
|
|
if (!sub) return;
|
|
this.wired = true;
|
|
sub.on('message', (channel: string, payload: string) => {
|
|
const set = this.handlers.get(channel);
|
|
if (!set || set.size === 0) return;
|
|
let parsed: any = payload;
|
|
try {
|
|
parsed = JSON.parse(payload);
|
|
} catch {
|
|
// Leave as raw string if it was not JSON.
|
|
}
|
|
for (const handler of set) {
|
|
try {
|
|
handler(parsed);
|
|
} catch (err: any) {
|
|
console.error('[pubsub] handler error:', err?.message || err);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async publish(channel: string, message: any): Promise<void> {
|
|
const redis = getRedis();
|
|
if (!redis) return;
|
|
try {
|
|
await redis.publish(channel, JSON.stringify(message));
|
|
} catch (err: any) {
|
|
console.error('[pubsub] publish error:', err?.message || err);
|
|
}
|
|
}
|
|
|
|
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
|
|
this.ensureWired();
|
|
const sub = getSubscriber();
|
|
if (!sub) return () => undefined;
|
|
|
|
let set = this.handlers.get(channel);
|
|
if (!set) {
|
|
set = new Set();
|
|
this.handlers.set(channel, set);
|
|
try {
|
|
await sub.subscribe(channel);
|
|
} catch (err: any) {
|
|
console.error('[pubsub] subscribe error:', err?.message || err);
|
|
}
|
|
}
|
|
set.add(handler);
|
|
|
|
return () => {
|
|
const current = this.handlers.get(channel);
|
|
if (!current) return;
|
|
current.delete(handler);
|
|
if (current.size === 0) {
|
|
this.handlers.delete(channel);
|
|
sub.unsubscribe(channel).catch(() => undefined);
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
// ==================== Selection ====================
|
|
|
|
let instance: PubSub | null = null;
|
|
|
|
export function getPubSub(): PubSub {
|
|
if (!instance) {
|
|
instance = isRedisEnabled() ? new RedisPubSub() : new MemoryPubSub();
|
|
}
|
|
return instance;
|
|
}
|