All files / collaborative-presence/src PresenceManager.ts

78.84% Statements 41/52
90% Branches 18/20
50% Functions 9/18
76.59% Lines 36/47

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 120 1211x 1x     1x   47x 47x 47x                     1x 30x   30x   30x 30x             43x 43x 43x 43x 38x 38x       12x       11x         5x 5x 5x 5x 7x 4x 4x     5x 5x       7x       3x 2x 2x 2x       4x 4x 4x                                                                                      
import {FanOut} from 'thingies/lib/fanout';
import {UserPresenceIdx} from './constants';
import type {JsonCrdtSelection, UserPresence} from './types';
 
export class PresenceEvent {
  constructor(
    public readonly added: string[],
    public readonly updated: string[],
    public readonly removed: string[],
  ) {}
}
 
export type PeerEntry<Meta extends object = object> = [presence: UserPresence<Meta>, receivedAt: number];
 
/**
 * Reactive in-memory presence store. Tracks remote peer states keyed by
 * `processId`. LWW by `seq` — stale updates are silently ignored. No internal
 * timers; the caller controls GC via {@link removeOutdated}.
 */
export class PresenceManager<Meta extends object = object> {
  public peers: Record<string, PeerEntry<Meta>> = {};
  public local: UserPresence;
  public readonly onChange: FanOut<PresenceEvent> = new FanOut<PresenceEvent>();
 
  constructor(public readonly timeout: number = 30_000) {
    this.local = ['', Math.random().toString(36).slice(2), 0, Math.floor(Date.now() / 1000), [], {} as Meta];
  }
 
  // ---------------------------------------------------------- remote presence
 
  /** LWW by `seq` per `processId` — stale updates are silently ignored. */
  receive(incoming: UserPresence<Meta>): void {
    const processId: string = incoming[UserPresenceIdx.ProcessId];
    const incomingSeq: number = incoming[UserPresenceIdx.Seq];
    const existing = this.peers[processId];
    if (existing && existing[0][UserPresenceIdx.Seq] >= incomingSeq) return;
    this.peers[processId] = [incoming, Date.now()];
    this.onChange.emit(new PresenceEvent(existing ? [] : [processId], existing ? [processId] : [], []));
  }
 
  get(processId: string): UserPresence<Meta> | undefined {
    return this.peers[processId]?.[0];
  }
 
  size(): number {
    return Object.keys(this.peers).length;
  }
 
  /** Remove peers whose `receivedAt` exceeds `timeout`. */
  removeOutdated(timeout: number = this.timeout): string[] {
    const now = Date.now();
    const removed: string[] = [];
    const peers = this.peers;
    for (const processId in peers) {
      if (now - peers[processId][1] > timeout) {
        delete peers[processId];
        removed.push(processId);
      }
    }
    if (removed.length) this.onChange.emit(new PresenceEvent([], [], removed));
    return removed;
  }
 
  merge(snapshot: UserPresence<Meta>[]): void {
    for (const incoming of snapshot) this.receive(incoming);
  }
 
  remove(processId: string): boolean {
    if (!(processId in this.peers)) return false;
    delete this.peers[processId];
    this.onChange.emit(new PresenceEvent([], [], [processId]));
    return true;
  }
 
  destroy(): void {
    const removed = Object.keys(this.peers);
    this.peers = {};
    if (removed.length) this.onChange.emit(new PresenceEvent([], [], removed));
  }
 
  // ----------------------------------------------------------- local presence
 
  setUserId(userId: string): void {
    this.local[UserPresenceIdx.UserId] = userId;
  }
 
  getUserId(): string {
    return this.local[UserPresenceIdx.UserId];
  }
 
  setProcessId(processId: string): void {
    this.local[UserPresenceIdx.ProcessId] = processId;
  }
 
  getProcessId(): string {
    return this.local[UserPresenceIdx.ProcessId];
  }
 
  setMeta(meta: Meta): void {
    this.local[UserPresenceIdx.Meta] = meta;
  }
 
  getMeta(): Meta {
    return this.local[UserPresenceIdx.Meta] as Meta;
  }
 
  setSelections(selections: JsonCrdtSelection[]): void {
    this.local[UserPresenceIdx.Seq]++;
    this.local[UserPresenceIdx.Ts] = Math.floor(Date.now() / 1000);
    this.local[UserPresenceIdx.Selections] = selections;
  }
 
  getSelections(): JsonCrdtSelection[] {
    return (this.local[UserPresenceIdx.Selections] as JsonCrdtSelection[]) || [];
  }
 
  clearSelections(): void {
    this.setSelections([]);
  }
}