| |
| |
| |
| |
| |
|
|
|
|
| export interface BadgeUnlockEvent {
|
| badgeId: string;
|
| badgeName: string;
|
| badgeDescription: string;
|
| badgeIcon: string;
|
| badgeRarity: string;
|
| unlockedAt: string;
|
| }
|
|
|
|
|
| const recentUnlocks: Map<string, { event: BadgeUnlockEvent; addedAt: number }[]> = new Map();
|
| const MAX_BUFFER_SIZE = 50;
|
| const BUFFER_TTL_MS = 60_000;
|
| const STALE_KEY_TTL_MS = 120_000;
|
|
|
| |
| |
| |
|
|
| 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() });
|
|
|
|
|
| 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);
|
| }
|
|
|
|
|
| const staleCutoff = Date.now() - STALE_KEY_TTL_MS;
|
| for (const [key, entries] of recentUnlocks) {
|
|
|
| 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);
|
| }
|
| }
|
| }
|
|
|
| |
| |
|
|
| export function consumeBadgeUnlocks(apiKeyId: string): BadgeUnlockEvent[] {
|
| const entries = recentUnlocks.get(apiKeyId) || [];
|
| recentUnlocks.delete(apiKeyId);
|
| return entries.map((e) => e.event);
|
| }
|
|
|
| |
| |
|
|
| 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);
|
| }
|
| };
|
|
|
|
|
| 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);
|
|
|
|
|
| const heartbeat = setInterval(() => {
|
| safeEnqueue(encoder.encode(`: heartbeat ${Date.now()}\n\n`));
|
| }, 15_000);
|
|
|
|
|
| const cleanup = () => {
|
| closed = true;
|
| clearInterval(interval);
|
| clearInterval(heartbeat);
|
| try {
|
| controller.close();
|
| } catch {
|
|
|
| }
|
| };
|
|
|
| if (signal) {
|
| signal.addEventListener("abort", cleanup);
|
| }
|
| },
|
| });
|
| }
|
|
|