buffer.ts7.1 KBView on GitHub

2163 error spans on 0 routes in the last 7 days.

import type { CaptureMode, TimelineEntry } from './types';

// The buffer always captures everything; `mode` is a view-time filter only
// (applied in TimelineDebuggerTab). The ring keeps just the last 100 events so the
// timeline stays small and focused on the most recent flow.
const DEFAULT_CAPACITY = 100;

const MAIL_PATH_PREFIXES = [
  'mail.',
  'inbox.',
  'threads.',
  'thread.',
  'labels.',
  'label.',
  'drafts.',
  'draft.',
  'messages.',
];

const MAIL_KEYS = new Set([
  'threadData',
  'threadIds',
  'selectedThreadId',
  'focusedIndex',
  'newEmail',
  'showImages',
  'bulkSelected',
  'selected',
  'currentConversationList',
  'value',
  'highlight',
  'folder',
  'category',
]);

interface BufferState {
  entries: TimelineEntry[];
  writeIndex: number;
  count: number;
  capacity: number;
  enabled: boolean;
  paused: boolean;
  mode: CaptureMode;
  captureBodies: boolean;
  version: number; // bumps on every push so subscribers can re-render
}

const STORAGE_KEY=[redacted];
const BODIES_STORAGE_KEY=[redacted];

function readPersistedFlag(key=[redacted], fallback: boolean): boolean {
  if (typeof localStorage === 'undefined') return fallback;
  try {
    const v = localStorage.getItem(key);
    if (v === null) return fallback;
    return v === '1' || v === 'true';
  } catch {
    return fallback;
  }
}

function writePersistedFlag(key=[redacted], value: boolean): void {
  if (typeof localStorage === 'undefined') return;
  try {
    localStorage.setItem(key, value ? '1' : '0');
  } catch {
    // ignore
  }
}

const isDev = typeof process !== 'undefined' && process.env.NODE_ENV === 'development';
const persistedEnabled = readPersistedFlag(STORAGE_KEY, false);

const state: BufferState = {
  entries: Array.from({ length: DEFAULT_CAPACITY }),
  writeIndex: 0,
  count: 0,
  capacity: DEFAULT_CAPACITY,
  enabled: isDev || persistedEnabled,
  paused: false,
  // Default to 'all' — capture is unconditional anyway; this just makes the
  // panel show everything by default rather than only mail-tagged rows.
  mode: 'all',
  captureBodies: readPersistedFlag(BODIES_STORAGE_KEY, false),
  version: 0,
};

const listeners = new Set<() => void>();

export function subscribe(fn: () => void): () => void {
  listeners.add(fn);
  return () => listeners.delete(fn);
}

function notify() {
  state.version++;
  for (const fn of listeners) fn();
}

export function getVersion(): number {
  return state.version;
}

export function getMode(): CaptureMode {
  return state.mode;
}

/**
 * `mode` is a view-time filter only — it controls what `TimelineDebuggerTab` renders
 * and copies, NOT what the buffer captures. Capture is always unconditional,
 * so switching to `all` never has a blind spot for past events.
 */
export function setMode(mode: CaptureMode): void {
  if (state.mode === mode) return;
  state.mode = mode;
  notify();
}

export function isPaused(): boolean {
  return state.paused;
}

export function setPaused(p: boolean): void {
  state.paused = p;
  notify();
}

export function isEnabled(): boolean {
  return state.enabled;
}

export function setEnabled(v: boolean): void {
  state.enabled = v;
  writePersistedFlag(STORAGE_KEY, v);
  notify();
}

export function isCaptureBodies(): boolean {
  return state.captureBodies;
}

export function setCaptureBodies(v: boolean): void {
  state.captureBodies = v;
  writePersistedFlag(BODIES_STORAGE_KEY, v);
  notify();
}

export function clear(): void {
  state.entries = Array.from({ length: state.capacity });
  state.writeIndex = 0;
  state.count = 0;
  notify();
}

export function replaceAll(newEntries: TimelineEntry[]): void {
  state.entries = Array.from({ length: state.capacity });
  state.writeIndex = 0;
  state.count = 0;
  for (const e of newEntries.slice(-state.capacity)) {
    state.entries[state.writeIndex] = e;
    state.writeIndex = (state.writeIndex + 1) % state.capacity;
    state.count = Math.min(state.count + 1, state.capacity);
  }
  notify();
}

let idCounter = 0;
function nextId(): string {
  idCounter++;
  return `${Date.now().toString(36)}-${idCounter.toString(36)}`;
}

export function isMailTagged(entry: TimelineEntry): boolean {
  if (entry.tags?.includes('mail')) return true;
  if (entry.source === 'trpc.request' || entry.source === 'trpc.response' || entry.source === 'trpc.error') {
    return MAIL_PATH_PREFIXES.some((p) => entry.path.startsWith(p));
  }
  if (entry.source === 'zustand') {
    return entry.changedKeys.some((k) => MAIL_KEYS.has(k));
  }
  // Errors, freezes, and notes are always interesting in mail mode
  if (entry.source === 'error' || entry.source === 'freeze' || entry.source === 'note') return true;
  return false;
}

/**
 * Patch the most recent tRPC entries (request/response/error) with the server's
 * X-Trace-ID and X-Request-ID headers. These are per HTTP batch, not per tRPC op,
 * so all entries from a given batch within `withinMs` get the same correlation IDs.
 */
export function attachTraceIdToRecent(
  ids: { traceId?: string; serverRequestId?: string },
  withinMs = 1500,
): void {
  if (!state.enabled) return;
  if (!ids.traceId && !ids.serverRequestId) return;
  const now = Date.now();
  let patched = 0;
  for (let i = 0; i < state.capacity && patched < 30; i++) {
    const idx = (state.writeIndex - 1 - i + state.capacity * 2) % state.capacity;
    const e = state.entries[idx];
    if (!e) continue;
    if (now - e.ts > withinMs) break;
    if (
      e.source !== 'trpc.request' &&
      e.source !== 'trpc.response' &&
      e.source !== 'trpc.error'
    ) {
      continue;
    }
    let touched = false;
    if (ids.traceId && !e.traceId) {
      e.traceId = ids.traceId;
      touched = true;
    }
    if (ids.serverRequestId && !e.serverRequestId) {
      e.serverRequestId = ids.serverRequestId;
      touched = true;
    }
    if (touched) patched++;
  }
  if (patched > 0) notify();
}

type PushInput = TimelineEntry extends infer E
  ? E extends TimelineEntry
    ? Omit<E, 'id' | 'ts'> & Partial<Pick<E, 'id' | 'ts'>>
    : never
  : never;

export function push(entryInput: PushInput): string | undefined {
  if (!state.enabled || state.paused) return undefined;
  // Capture is unconditional — every tRPC op, zustand change, SSE event, etc.
  // is stored regardless of `mode`. `mode` filtering happens at render time in
  // TimelineDebuggerTab (`isMailTagged`), so it never loses data.
  const entry = {
    ...entryInput,
    id: (entryInput as { id?: string }).id ?? nextId(),
    ts: (entryInput as { ts?: number }).ts ?? Date.now(),
  } as TimelineEntry;

  state.entries[state.writeIndex] = entry;
  state.writeIndex = (state.writeIndex + 1) % state.capacity;
  state.count = Math.min(state.count + 1, state.capacity);
  notify();
  return entry.id;
}

export function readAll(): TimelineEntry[] {
  const out: TimelineEntry[] = [];
  if (state.count < state.capacity) {
    for (let i = 0; i < state.count; i++) out.push(state.entries[i]!);
  } else {
    for (let i = 0; i < state.capacity; i++) {
      const idx = (state.writeIndex + i) % state.capacity;
      const e = state.entries[idx];
      if (e) out.push(e);
    }
  }
  return out;
}

export function getCount(): number {
  return state.count;
}