Spaces:
Runtime error
Runtime error
File size: 3,321 Bytes
cd8bd0a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 | /**
* Badge unlock notification system.
* Emits events that the dashboard can listen to for toast notifications.
*
* @module lib/gamification/notifications
*/
export interface BadgeUnlockEvent {
badgeId: string;
badgeName: string;
badgeDescription: string;
badgeIcon: string;
badgeRarity: string;
unlockedAt: string;
}
// In-memory event buffer for SSE streaming
const recentUnlocks: Map<string, { event: BadgeUnlockEvent; addedAt: number }[]> = new Map();
const MAX_BUFFER_SIZE = 50;
const BUFFER_TTL_MS = 60_000; // 1 minute
const STALE_KEY_TTL_MS = 120_000; // 2 minutes for stale key cleanup
/**
* Record a badge unlock event for notification.
* Also cleans stale entries across all keys to prevent memory leaks.
*/
export function recordBadgeUnlock(apiKeyId: string, event: BadgeUnlockEvent): void {
if (!recentUnlocks.has(apiKeyId)) {
recentUnlocks.set(apiKeyId, []);
}
const list = recentUnlocks.get(apiKeyId)!;
list.push({ event, addedAt: Date.now() });
// Trim old entries for this key
const cutoff = Date.now() - BUFFER_TTL_MS;
while (list.length > 0 && list[0].addedAt < cutoff) {
list.shift();
}
if (list.length > MAX_BUFFER_SIZE) {
list.splice(0, list.length - MAX_BUFFER_SIZE);
}
// Periodic stale key cleanup (on each record, check all keys)
const staleCutoff = Date.now() - STALE_KEY_TTL_MS;
for (const [key, entries] of recentUnlocks) {
// Remove old entries
const fresh = entries.filter((e) => e.addedAt >= staleCutoff);
if (fresh.length === 0) {
recentUnlocks.delete(key);
} else if (fresh.length !== entries.length) {
recentUnlocks.set(key, fresh);
}
}
}
/**
* Get and clear recent badge unlocks for an API key.
*/
export function consumeBadgeUnlocks(apiKeyId: string): BadgeUnlockEvent[] {
const entries = recentUnlocks.get(apiKeyId) || [];
recentUnlocks.delete(apiKeyId);
return entries.map((e) => e.event);
}
/**
* Create a ReadableStream for badge unlock notifications via SSE.
*/
export function createBadgeNotificationStream(
apiKeyId: string,
signal?: AbortSignal
): ReadableStream {
return new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
let closed = false;
const safeEnqueue = (chunk: Uint8Array) => {
if (closed) return;
try {
controller.enqueue(chunk);
} catch {
closed = true;
clearInterval(interval);
clearInterval(heartbeat);
}
};
// Check for unlocks every 2s
const interval = setInterval(() => {
const events = consumeBadgeUnlocks(apiKeyId);
for (const event of events) {
safeEnqueue(encoder.encode(`event: badge_unlock\ndata: ${JSON.stringify(event)}\n\n`));
}
}, 2000);
// Heartbeat every 15s
const heartbeat = setInterval(() => {
safeEnqueue(encoder.encode(`: heartbeat ${Date.now()}\n\n`));
}, 15_000);
// Cleanup on abort
const cleanup = () => {
closed = true;
clearInterval(interval);
clearInterval(heartbeat);
try {
controller.close();
} catch {
/* already closed */
}
};
if (signal) {
signal.addEventListener("abort", cleanup);
}
},
});
}
|