Ctrl K
ComunidadGuías…

utils/monitor.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { createHash, randomUUID } from "node:crypto";
import { BlockList, isIP } from "node:net";
import { userAgent } from "next/server";
import type {
  ClientInfo,
  LocationStat,
  MonitorEvent,
  VisitLogEntry,
  ViewerStatus,
} from "../types/status";

// A polling client that hasn't fetched for this long has left. The slowest
// fallback interval is 5 min, so idle pollers drop off between polls
const POLLER_TIMEOUT_MS = 6 * 60_000;
const MAX_EVENTS = 100;
const DAY_MS = 24 * 60 * 60_000;
const WEEK_MS = 7 * DAY_MS;
// Reconnects and polling fallbacks within this gap are the same visit
const VISIT_GAP_MS = 30 * 60_000;
const MAX_LOCATIONS = 500;
const MAX_VISIT_LOG = 500;

interface LocationEntry extends LocationStat {
  // Visitor key → last seen at this location
  visitorKeys: Map<string, number>;
}

/**
 * In-memory record of who is watching, for the owner's status page. Nothing
 * is persisted, so stats cover the time since the process started.
 */
class Monitor {
  readonly startedAt = Date.now();
  private streams = new Map<string, ViewerStatus>();
  private pollers = new Map<string, ViewerStatus>();
  private events: MonitorEvent[] = [];
  // Visitor key → last seen; keys are hashed so raw IPs don't outlive a visit
  private visitors = new Map<string, number>();
  private connections: number[] = [];
  private peak = { count: 0, at: null as number | null };
  // Kept for the life of the process, least recently seen first
  private locations = new Map<string, LocationEntry>();
  private visitLog: VisitLogEntry[] = [];

  /** Registers an SSE connection; call the returned function when it closes. */
  trackStream(client: ClientInfo): () => void {
    const now = Date.now();
    const id = randomUUID();
    this.streams.set(id, { ...client, id, mode: "stream", connectedAt: now, lastSeen: now });
    // It may have been polling until the stream came back
    this.pollers.delete(clientKey(client));
    this.noteVisit(client, now);
    return () => {
      this.streams.delete(id);
    };
  }

  /** Records a polling-fallback session fetch. */
  recordPoll(client: ClientInfo): void {
    const now = Date.now();
    const key = clientKey(client);
    const existing = this.pollers.get(key);
    if (existing) {
      existing.lastSeen = now;
      return;
    }
    this.pollers.set(key, { ...client, id: key, mode: "polling", connectedAt: now, lastSeen: now });
    this.noteVisit(client, now);
  }

  log(level: MonitorEvent["level"], message: string): void {
    this.events.push({ at: Date.now(), level, message });
    if (this.events.length > MAX_EVENTS) this.events.shift();
  }

  viewers(): ViewerStatus[] {
    this.prune(Date.now());
    return [...this.streams.values(), ...this.pollers.values()].sort(
      (a, b) => a.connectedAt - b.connectedAt
    );
  }

  stats() {
    const now = Date.now();
    this.prune(now);
    let day = 0;
    for (const lastSeen of this.visitors.values()) if (now - lastSeen < DAY_MS) day++;
    return {
      peakViewers: this.peak.count,
      peakAt: this.peak.at,
      uniqueVisitors24h: day,
      uniqueVisitors7d: this.visitors.size,
      connections24h: this.connections.length,
    };
  }

  recentEvents(): MonitorEvent[] {
    return [...this.events].reverse();
  }

  /** Every location seen since start, most recent first. */
  locationStats(): LocationStat[] {
    return [...this.locations.values()].reverse().map((entry) => ({
      local: entry.local,
      country: entry.country,
      region: entry.region,
      city: entry.city,
      visitors: entry.visitors,
      visits: entry.visits,
      firstSeen: entry.firstSeen,
      lastSeen: entry.lastSeen,
    }));
  }

  /** Recent visits, newest first. */
  recentVisits(): VisitLogEntry[] {
    return [...this.visitLog].reverse();
  }

  private noteVisit(client: ClientInfo, now: number): void {
    if (client.bot) return;
    const visitor = hashKey(client.ip);
    this.visitors.set(visitor, now);
    this.connections.push(now);
    const count = this.streams.size + this.pollers.size;
    if (count > this.peak.count) this.peak = { count, at: now };
    this.noteLocation(client, visitor, now);
  }

  private noteLocation(client: ClientInfo, visitor: string, now: number): void {
    const { local } = client;
    const place = local
      ? { local }
      : { local, country: client.country, region: client.region, city: client.city };
    const key = local ? "local" : `${place.country ?? ""}|${place.region ?? ""}|${place.city ?? ""}`;

    let entry = this.locations.get(key);
    if (entry) {
      // Re-insert so the map stays ordered by last seen
      this.locations.delete(key);
    } else {
      entry = { ...place, visitors: 0, visits: 0, firstSeen: now, lastSeen: now, visitorKeys: new Map() };
    }
    this.locations.set(key, entry);
    entry.lastSeen = now;

    const previous = entry.visitorKeys.get(visitor);
    entry.visitorKeys.set(visitor, now);
    if (previous === undefined) entry.visitors++;
    if (previous === undefined || now - previous >= VISIT_GAP_MS) {
      entry.visits++;
      this.visitLog.push({
        ...place,
        at: now,
        visitor: visitor.slice(0, 6),
        browser: client.browser,
        os: client.os,
        device: client.device,
      });
      if (this.visitLog.length > MAX_VISIT_LOG) this.visitLog.shift();
    }

    if (this.locations.size > MAX_LOCATIONS) {
      const oldest = this.locations.keys().next().value;
      if (oldest !== undefined) this.locations.delete(oldest);
    }
  }

  private prune(now: number): void {
    for (const [key, poller] of this.pollers) {
      if (now - poller.lastSeen > POLLER_TIMEOUT_MS) this.pollers.delete(key);
    }
    for (const [key, lastSeen] of this.visitors) {
      if (now - lastSeen > WEEK_MS) this.visitors.delete(key);
    }
    while (this.connections.length && now - this.connections[0] > DAY_MS) {
      this.connections.shift();
    }
  }
}

function hashKey(value: string): string {
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
}

function clientKey(client: ClientInfo): string {
  return hashKey(`${client.ip}|${client.browser}|${client.os}|${client.device}`);
}

// Loopback, private, CGNAT (Tailscale and similar) and link-local ranges
const LOCAL_RANGES = new BlockList();
for (const [network, prefix] of [
  ["127.0.0.0", 8],
  ["10.0.0.0", 8],
  ["172.16.0.0", 12],
  ["192.168.0.0", 16],
  ["100.64.0.0", 10],
  ["169.254.0.0", 16],
] as const) {
  LOCAL_RANGES.addSubnet(network, prefix, "ipv4");
}
for (const [network, prefix] of [
  ["::1", 128],
  ["fc00::", 7],
  ["fe80::", 10],
] as const) {
  LOCAL_RANGES.addSubnet(network, prefix, "ipv6");
}

// Node reports IPv4 clients on dual-stack sockets as ::ffff:a.b.c.d
function normalizeIp(ip: string): string {
  return ip.toLowerCase().startsWith("::ffff:") && isIP(ip.slice(7)) === 4 ? ip.slice(7) : ip;
}

function isLocalIp(ip: string): boolean {
  const family = isIP(ip);
  return family !== 0 && LOCAL_RANGES.check(ip, family === 4 ? "ipv4" : "ipv6");
}

interface HasHeaders {
  headers: Headers;
}

function header(request: HasHeaders, name: string): string | undefined {
  const value = request.headers.get(name)?.trim();
  return value || undefined;
}

/**
 * Who sent a request. Behind Cloudflare the IP and location come from its
 * headers; anyone reaching the origin directly could spoof them, which only
 * matters for these stats.
 */
export function describeClient(request: HasHeaders): ClientInfo {
  const forwarded = header(request, "x-forwarded-for")?.split(",")[0]?.trim();
  const ip = normalizeIp(
    header(request, "cf-connecting-ip") ?? forwarded ?? header(request, "x-real-ip") ?? "unknown"
  );

  // XX = unknown, T1 = Tor
  const rawCountry = header(request, "cf-ipcountry");
  const country = rawCountry && rawCountry !== "XX" ? rawCountry : undefined;

  const ua = userAgent(request);
  const deviceType = ua.device.type;
  const device: ClientInfo["device"] =
    deviceType === undefined
      ? "desktop"
      : deviceType === "mobile" || deviceType === "tablet"
        ? deviceType
        : "other";

  return {
    ip,
    local: isLocalIp(ip),
    country,
    region: header(request, "cf-region"),
    city: header(request, "cf-ipcity"),
    browser: ua.browser.name,
    os: ua.os.name,
    device,
    bot: ua.isBot,
  };
}

// Shared across route bundles and dev hot reloads, like the session hub
const globalForMonitor = globalThis as typeof globalThis & { __monitor?: Monitor };

export const monitor = (globalForMonitor.__monitor ??= new Monitor());