diff --git a/app/api/presence/visitors/route.ts b/app/api/presence/visitors/route.ts index c0b2969..019a10a 100644 --- a/app/api/presence/visitors/route.ts +++ b/app/api/presence/visitors/route.ts @@ -7,6 +7,7 @@ import { updateVisitorPresence } from "@/lib/presence"; const inputSchema = z.strictObject({ tabId: z.string().uuid(), + browserId: z.string().uuid(), guideId: z.string().uuid().nullable(), active: z.boolean().default(true), }); diff --git a/hooks/use-presence.ts b/hooks/use-presence.ts index 465d41c..50d83c2 100644 --- a/hooks/use-presence.ts +++ b/hooks/use-presence.ts @@ -58,7 +58,8 @@ function useHeartbeat({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - tabId: identifyBy === "browser" ? visitorId() : tabId(), + tabId: tabId(), + ...(identifyBy === "browser" ? { browserId: visitorId() } : {}), guideId, ...(section !== undefined ? { section } : {}), active, @@ -82,7 +83,7 @@ function useHeartbeat({ if (document.visibilityState === "visible") void send(); }; const leave = () => { - if (identifyBy === "tab") void send(false); + void send(false); }; window.addEventListener("focus", refresh); window.addEventListener("pagehide", leave); diff --git a/lib/presence.ts b/lib/presence.ts index 126ea9b..4405491 100644 --- a/lib/presence.ts +++ b/lib/presence.ts @@ -36,16 +36,19 @@ async function purge( export async function updateVisitorPresence({ tabId, + browserId, guideId, active, }: { tabId: string; + browserId: string; guideId: string | null; active: boolean; }) { const redis = await getRedisClient(); const activeKey = `${prefix()}:visitors`; const scopeKey = `${prefix()}:visitor-scopes`; + const browserKey = `${prefix()}:visitor-browsers`; const now = Date.now(); const serialized = await redis.eval(` local now = tonumber(ARGV[1]) @@ -53,31 +56,52 @@ export async function updateVisitorPresence({ for _, id in ipairs(expired) do redis.call('ZREM', KEYS[1], id) redis.call('HDEL', KEYS[2], id) + redis.call('HDEL', KEYS[3], id) end if ARGV[5] == '1' then if redis.call('ZCARD', KEYS[1]) >= 10000 and not redis.call('ZSCORE', KEYS[1], ARGV[2]) then return redis.error_reply('presence capacity reached') end + local previousScope = redis.call('HGET', KEYS[2], ARGV[2]) + local previousBrowser = redis.call('HGET', KEYS[3], ARGV[2]) redis.call('ZADD', KEYS[1], now + tonumber(ARGV[4]), ARGV[2]) redis.call('HSET', KEYS[2], ARGV[2], ARGV[3]) + redis.call('HSET', KEYS[3], ARGV[2], ARGV[6]) + if previousScope ~= ARGV[3] or previousBrowser ~= ARGV[6] then redis.call('DEL', KEYS[4]) end else redis.call('ZREM', KEYS[1], ARGV[2]) redis.call('HDEL', KEYS[2], ARGV[2]) + redis.call('HDEL', KEYS[3], ARGV[2]) + redis.call('DEL', KEYS[4]) end redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[4]) * 2) redis.call('PEXPIRE', KEYS[2], tonumber(ARGV[4]) * 2) - local snapshot = redis.call('GET', KEYS[3]) + redis.call('PEXPIRE', KEYS[3], tonumber(ARGV[4]) * 2) + if #expired > 0 then redis.call('DEL', KEYS[4]) end + local snapshot = redis.call('GET', KEYS[4]) if snapshot then return snapshot end local ids = redis.call('ZRANGEBYSCORE', KEYS[1], now + 1, '+inf') local counts = {} + local browsers = {} + local guideBrowsers = {} for _, id in ipairs(ids) do + local browser = redis.call('HGET', KEYS[3], id) or id local scope = redis.call('HGET', KEYS[2], id) - if scope and scope ~= '' then counts[scope] = (counts[scope] or 0) + 1 end + browsers[browser] = true + if scope and scope ~= '' then + local key = scope .. ':' .. browser + if not guideBrowsers[key] then + counts[scope] = (counts[scope] or 0) + 1 + guideBrowsers[key] = true + end + end end - snapshot = cjson.encode({global = #ids, byGuide = counts}) - redis.call('SET', KEYS[3], snapshot, 'PX', 5000) + local global = 0 + for _ in pairs(browsers) do global = global + 1 end + snapshot = cjson.encode({global = global, byGuide = counts}) + redis.call('SET', KEYS[4], snapshot, 'PX', 5000) return snapshot - `, 3, activeKey, scopeKey, `${prefix()}:visitor-counts`, now, tabId, guideId ?? "", PRESENCE_TTL_MS, active ? "1" : "0") as string; + `, 4, activeKey, scopeKey, browserKey, `${prefix()}:visitor-counts`, now, tabId, guideId ?? "", PRESENCE_TTL_MS, active ? "1" : "0", browserId) as string; const snapshot = JSON.parse(serialized) as { global: number; byGuide: Record }; return { ...snapshot, guide: guideId ? snapshot.byGuide[guideId] ?? 0 : null }; } diff --git a/tests/security-redis.integration.test.ts b/tests/security-redis.integration.test.ts index 15dceb9..6843144 100644 --- a/tests/security-redis.integration.test.ts +++ b/tests/security-redis.integration.test.ts @@ -54,14 +54,33 @@ describeWithRedis("shared Redis security paths", () => { }); it("tracks visitor counts without a separate Redis round trip per visitor", async () => { - const first = await updateVisitorPresence({ tabId: `${runId}:1`, guideId: "guide-a", active: true }); + const first = await updateVisitorPresence({ tabId: `${runId}:1`, browserId: `${runId}:browser-1`, guideId: "guide-a", active: true }); expect(first.global).toBe(1); expect(first.guide).toBe(1); - await updateVisitorPresence({ tabId: `${runId}:2`, guideId: "guide-a", active: true }); + const second = await updateVisitorPresence({ tabId: `${runId}:2`, browserId: `${runId}:browser-2`, guideId: "guide-a", active: true }); + expect(second.global).toBe(2); + expect(second.guide).toBe(2); const count = await publisher.zcard(`${process.env.REDIS_PRESENCE_PREFIX}:visitors`); expect(count).toBe(2); }); + it("counts a browser once across tabs without losing either guide", async () => { + const browserId = `${runId}:browser-3`; + const firstTab = `${runId}:3`; + const secondTab = `${runId}:4`; + const firstGuide = await updateVisitorPresence({ tabId: firstTab, browserId, guideId: "guide-c", active: true }); + const secondGuide = await updateVisitorPresence({ tabId: secondTab, browserId, guideId: "guide-d", active: true }); + expect(secondGuide.global).toBe(firstGuide.global); + expect(secondGuide.guide).toBe(1); + expect(secondGuide.byGuide["guide-c"]).toBe(1); + const sameGuide = await updateVisitorPresence({ tabId: secondTab, browserId, guideId: "guide-c", active: true }); + expect(sameGuide.guide).toBe(1); + expect(sameGuide.byGuide["guide-d"]).toBeUndefined(); + const afterLeave = await updateVisitorPresence({ tabId: secondTab, browserId, guideId: "guide-d", active: false }); + expect(afterLeave.byGuide["guide-c"]).toBe(1); + expect(afterLeave.byGuide["guide-d"]).toBeUndefined(); + }); + it("fans one subscribed topic out to two SSE clients", async () => { const topic = `page:${runId}`; const first = new AbortController();