Rindle

API index and search · Build metadata

Source snapshot

packages/optimistic/src/client-id.ts

Source revision 05d0bf2c2e56 · build details
Source revision: 05d0bf2c2e56.
TypeScript input SHA-256: aabe6cfcc4172b870d5e272142958e9ea8d8784c2aa23133156e5a7ee633318e
Generated 2026-09-04T23:58:25.590Z with TypeScript 6.0.3. Public TypeScript checks and declaration emit passed. Package runtime tests are separate.
1import type { TicketPersistence } from "@rindle/remote";23// The connection's stable clientID — kept in its own (wasm-free) module so it can be unit-tested4// without pulling the engine in. A per-ORIGIN base (localStorage) shared by every tab and kept5// across reloads, a per-TAB suffix (sessionStorage) that survives a reload within a tab but is6// distinct per tab, and a per-LOAD instance suffix that keeps two clients in one tab distinct.7// Without these two tabs (or two clients in one tab) would share a clientID and collide on the8// server's per-clientID mid sequence (a confirmed-mid-ahead-of-issued throw, OPTIMISTIC-WRITES §8.2).910/** The per-ORIGIN base id (localStorage): one logical identity shared by every tab and kept11 *  across reloads. */12const CLIENT_ID_KEY = "rindle-client-id";13/** The per-TAB suffix (sessionStorage): survives a reload within a tab but is distinct per tab. */14const TAB_ID_KEY = "rindle-tab-id";15/** The per-TAB follower-affinity ticket (sessionStorage): kept across a reload WITHIN a tab (so a16 *  reload lands back on the same follower — monotone reads survive it, FOLLOWER-AFFINITY-DESIGN.md17 *  §8), but DISTINCT per tab so two tabs can pin two regions (design §13; a shared localStorage18 *  ticket would force both onto one follower). The ticket is opaque and short-lived — the follower19 *  re-mints one on every connect — so losing it (no sessionStorage) merely re-anycasts. */20const AFFINITY_TICKET_KEY = "rindle-affinity-ticket";2122/** Read-or-mint a value in a web Storage area, tolerating its absence (SSR, privacy mode). Returns23 *  `undefined` when the area is unavailable so the caller can pick a fallback. */24function persistedId(area: "localStorage" | "sessionStorage", key: string, mint: () => string): string | undefined {25  try {26    const storage = (globalThis as unknown as Record<string, Storage | undefined>)[area];27    if (!storage) return undefined;28    const existing = storage.getItem(key);29    if (existing) return existing;30    const fresh = mint();31    storage.setItem(key, fresh);32    return fresh;33  } catch {34    return undefined;35  }36}3738/** Per-PAGE-LOAD instance counter. The FIRST client created in a tab keeps the bare tab suffix (so a39 *  reload resumes its mid sequence and adds no row); each ADDITIONAL client constructed in the same40 *  load gets a distinct instance suffix, so two clients in one tab never share a mid stream. Held in41 *  memory so it resets on reload — the sole client then reclaims the bare, reload-stable suffix. */42let tabInstance = 0;4344/** Test-only: reset the per-load instance counter to simulate a fresh page load. Deliberately NOT45 *  re-exported from the package barrel. */46export function resetTabInstanceForTests(): void {47  tabInstance = 0;48}4950/** Clear the persisted client identity so the next page load starts a fresh mid/lmid stream.51 *  Intended for dev recovery after out-of-band server state loss, not for normal operation. */52export function resetStableClientID(): void {53  try {54    globalThis.localStorage?.removeItem(CLIENT_ID_KEY);55  } catch {56    // Storage can throw in private modes or test shims; best-effort reset.57  }58  try {59    globalThis.sessionStorage?.removeItem(TAB_ID_KEY);60  } catch {61    // Storage can throw in private modes or test shims; best-effort reset.62  }63  tabInstance = 0;64}6566/** A sessionStorage-backed {@link TicketPersistence} for the affinity ticket — per-tab, survives a67 *  reload (see {@link AFFINITY_TICKET_KEY}). Every access tolerates web storage being absent (SSR,68 *  private mode): the store then behaves as pure in-memory, re-anycasting on each fresh load. */69export function sessionTicketPersistence(): TicketPersistence {70  const area = () => {71    try {72      return (globalThis as unknown as Record<string, Storage | undefined>).sessionStorage;73    } catch {74      return undefined;75    }76  };77  return {78    load: () => {79      try {80        return area()?.getItem(AFFINITY_TICKET_KEY) ?? undefined;81      } catch {82        return undefined;83      }84    },85    save: (ticket) => {86      try {87        area()?.setItem(AFFINITY_TICKET_KEY, ticket);88      } catch {89        // Best-effort persistence; a full/blocked store just means we re-anycast on the next load.90      }91    },92    clear: () => {93      try {94        area()?.removeItem(AFFINITY_TICKET_KEY);95      } catch {96        // Best-effort.97      }98    },99  };100}101102/** The connection's stable clientID. A per-origin base (localStorage) keeps one logical identity103 *  across reloads; a per-tab suffix (sessionStorage) gives each tab its OWN mid/lmid stream; a104 *  per-load instance suffix keeps two clients constructed in ONE tab from sharing that stream. Falls105 *  back to a fresh random id when web storage is unavailable.106 *107 *  Note: an explicit "duplicate tab" copies sessionStorage, so the clone briefly shares its source's108 *  suffix until reloaded — the one residual case of the shared-clientID collision. */109export function stableClientID(): string {110  const base = persistedId("localStorage", CLIENT_ID_KEY, () => crypto.randomUUID());111  if (!base) return crypto.randomUUID(); // no localStorage: session-scoped, unique per load112  // With localStorage but no sessionStorage, mint a fresh suffix each load so tabs still differ.113  const tab =114    persistedId("sessionStorage", TAB_ID_KEY, () => crypto.randomUUID().slice(0, 8)) ??115    crypto.randomUUID().slice(0, 8);116  // Instance 0 keeps the bare tab suffix (reload-stable, no extra row); extra concurrent clients in117  // one tab get `.N` so they can't collide on the server's per-clientID mid sequence.118  const instance = tabInstance++;119  const suffix = instance === 0 ? tab : `${tab}.${instance}`;120  return `${base}-${suffix}`;121}122