@buoy-gg/events 3.0.1 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/lib/commonjs/components/EventsCopySettingsView.js.map +1 -0
  2. package/lib/commonjs/components/EventsModal.js.map +1 -0
  3. package/lib/commonjs/components/ReactQueryEventDetail.js.map +1 -0
  4. package/lib/commonjs/components/UnifiedEventDetail.js +34 -10
  5. package/lib/commonjs/components/UnifiedEventDetail.js.map +1 -0
  6. package/lib/commonjs/components/UnifiedEventFilters.js.map +1 -0
  7. package/lib/commonjs/components/UnifiedEventItem.js.map +1 -0
  8. package/lib/commonjs/components/UnifiedEventList.js.map +1 -0
  9. package/lib/commonjs/components/UnifiedEventViewer.js.map +1 -0
  10. package/lib/commonjs/hooks/useUnifiedEvents.js +57 -27
  11. package/lib/commonjs/hooks/useUnifiedEvents.js.map +1 -0
  12. package/lib/commonjs/index.js +21 -0
  13. package/lib/commonjs/index.js.map +1 -0
  14. package/lib/commonjs/preset.js.map +1 -0
  15. package/lib/commonjs/sources/sourceIds.js +30 -0
  16. package/lib/commonjs/sources/sourceIds.js.map +1 -0
  17. package/lib/commonjs/stores/unifiedEventStore.js +185 -14
  18. package/lib/commonjs/stores/unifiedEventStore.js.map +1 -0
  19. package/lib/commonjs/sync/eventsSyncAdapter.js +62 -0
  20. package/lib/commonjs/sync/eventsSyncAdapter.js.map +1 -0
  21. package/lib/commonjs/types/copySettings.js.map +1 -0
  22. package/lib/commonjs/types/index.js.map +1 -0
  23. package/lib/commonjs/utils/autoDiscoverEventSources.js.map +1 -0
  24. package/lib/commonjs/utils/badgeSelectionStorage.js.map +1 -0
  25. package/lib/commonjs/utils/copySettingsStorage.js.map +1 -0
  26. package/lib/commonjs/utils/correlationUtils.js.map +1 -0
  27. package/lib/commonjs/utils/eventExportFormatter.js.map +1 -0
  28. package/lib/commonjs/utils/eventTransformers.js.map +1 -0
  29. package/lib/module/components/EventsCopySettingsView.js.map +1 -0
  30. package/lib/module/components/EventsModal.js.map +1 -0
  31. package/lib/module/components/ReactQueryEventDetail.js.map +1 -0
  32. package/lib/module/components/UnifiedEventDetail.js +36 -12
  33. package/lib/module/components/UnifiedEventDetail.js.map +1 -0
  34. package/lib/module/components/UnifiedEventFilters.js.map +1 -0
  35. package/lib/module/components/UnifiedEventItem.js.map +1 -0
  36. package/lib/module/components/UnifiedEventList.js.map +1 -0
  37. package/lib/module/components/UnifiedEventViewer.js.map +1 -0
  38. package/lib/module/hooks/useUnifiedEvents.js +59 -29
  39. package/lib/module/hooks/useUnifiedEvents.js.map +1 -0
  40. package/lib/module/index.js +9 -1
  41. package/lib/module/index.js.map +1 -0
  42. package/lib/module/preset.js.map +1 -0
  43. package/lib/module/sources/sourceIds.js +26 -0
  44. package/lib/module/sources/sourceIds.js.map +1 -0
  45. package/lib/module/stores/unifiedEventStore.js +185 -14
  46. package/lib/module/stores/unifiedEventStore.js.map +1 -0
  47. package/lib/module/sync/eventsSyncAdapter.js +58 -0
  48. package/lib/module/sync/eventsSyncAdapter.js.map +1 -0
  49. package/lib/module/types/copySettings.js.map +1 -0
  50. package/lib/module/types/index.js.map +1 -0
  51. package/lib/module/utils/autoDiscoverEventSources.js.map +1 -0
  52. package/lib/module/utils/badgeSelectionStorage.js.map +1 -0
  53. package/lib/module/utils/copySettingsStorage.js.map +1 -0
  54. package/lib/module/utils/correlationUtils.js.map +1 -0
  55. package/lib/module/utils/eventExportFormatter.js.map +1 -0
  56. package/lib/module/utils/eventTransformers.js.map +1 -0
  57. package/lib/typescript/components/EventsCopySettingsView.d.ts.map +1 -0
  58. package/lib/typescript/components/EventsModal.d.ts.map +1 -0
  59. package/lib/typescript/components/ReactQueryEventDetail.d.ts.map +1 -0
  60. package/lib/typescript/components/UnifiedEventDetail.d.ts +20 -0
  61. package/lib/typescript/components/UnifiedEventDetail.d.ts.map +1 -0
  62. package/lib/typescript/components/UnifiedEventFilters.d.ts.map +1 -0
  63. package/lib/typescript/components/UnifiedEventItem.d.ts.map +1 -0
  64. package/lib/typescript/components/UnifiedEventList.d.ts.map +1 -0
  65. package/lib/typescript/components/UnifiedEventViewer.d.ts.map +1 -0
  66. package/lib/typescript/hooks/useUnifiedEvents.d.ts.map +1 -0
  67. package/lib/typescript/index.d.ts +5 -0
  68. package/lib/typescript/index.d.ts.map +1 -0
  69. package/lib/typescript/preset.d.ts.map +1 -0
  70. package/lib/typescript/sources/sourceIds.d.ts +13 -0
  71. package/lib/typescript/sources/sourceIds.d.ts.map +1 -0
  72. package/lib/typescript/stores/unifiedEventStore.d.ts +49 -2
  73. package/lib/typescript/stores/unifiedEventStore.d.ts.map +1 -0
  74. package/lib/typescript/sync/eventsSyncAdapter.d.ts +37 -0
  75. package/lib/typescript/sync/eventsSyncAdapter.d.ts.map +1 -0
  76. package/lib/typescript/types/copySettings.d.ts.map +1 -0
  77. package/lib/typescript/types/index.d.ts.map +1 -0
  78. package/lib/typescript/utils/autoDiscoverEventSources.d.ts.map +1 -0
  79. package/lib/typescript/utils/badgeSelectionStorage.d.ts.map +1 -0
  80. package/lib/typescript/utils/copySettingsStorage.d.ts.map +1 -0
  81. package/lib/typescript/utils/correlationUtils.d.ts.map +1 -0
  82. package/lib/typescript/utils/eventExportFormatter.d.ts.map +1 -0
  83. package/lib/typescript/utils/eventTransformers.d.ts.map +1 -0
  84. package/package.json +3 -3
  85. package/src/components/UnifiedEventDetail.tsx +51 -7
  86. package/src/hooks/useUnifiedEvents.ts +74 -28
  87. package/src/index.tsx +9 -1
  88. package/src/sources/sourceIds.ts +25 -0
  89. package/src/stores/unifiedEventStore.ts +197 -16
  90. package/src/sync/eventsSyncAdapter.ts +56 -0
@@ -7,12 +7,13 @@
7
7
  */
8
8
 
9
9
  import { View, Text, StyleSheet, ScrollView } from "react-native";
10
- import { memo, useMemo, type ComponentType } from "react";
10
+ import { memo, useMemo, type ComponentType, type ReactNode } from "react";
11
11
  import {
12
12
  buoyColors,
13
13
  formatRelativeTime,
14
14
  copyToClipboard,
15
15
  parseValue,
16
+ useIgnoredPatterns,
16
17
  } from "@buoy-gg/shared-ui";
17
18
  import { DataViewer } from "@buoy-gg/shared-ui/dataViewer";
18
19
  import { ReactQueryEventDetail } from "./ReactQueryEventDetail";
@@ -44,6 +45,8 @@ interface OptionalDetailComponents {
44
45
  }> | null;
45
46
  NetworkEventDetailView: ComponentType<{
46
47
  event: unknown;
48
+ ignoredPatterns?: Set<string>;
49
+ onTogglePattern?: (value: string) => void;
47
50
  }> | null;
48
51
  RenderDetailView: ComponentType<{
49
52
  render: unknown;
@@ -183,17 +186,46 @@ function getValueType(
183
186
  // Component
184
187
  // ============================================================================
185
188
 
189
+ /** Passthrough used when no network detail wrapper is injected. */
190
+ function PassThrough({ children }: { children: ReactNode }) {
191
+ return <>{children}</>;
192
+ }
193
+
186
194
  interface UnifiedEventDetailProps {
187
195
  event: UnifiedEvent;
188
196
  onBack?: () => void;
197
+ /**
198
+ * Optional override for the network detail component. The desktop dashboard
199
+ * injects the real `@buoy-gg/network` export here because the runtime
200
+ * `require("@buoy-gg/network")` fallback is unreliable in its Vite/ESM build.
201
+ * When omitted, the dynamically-required component is used (works on mobile).
202
+ */
203
+ NetworkEventDetailViewComponent?: ComponentType<{
204
+ event: unknown;
205
+ ignoredPatterns?: Set<string>;
206
+ onTogglePattern?: (value: string) => void;
207
+ }>;
208
+ /**
209
+ * Optional wrapper rendered around the network detail view — e.g. the desktop
210
+ * passes `NetworkBodyResolverProvider` so large request/response bodies that
211
+ * were stripped from the sync snapshot can be fetched from the device on demand.
212
+ */
213
+ networkDetailWrapper?: ComponentType<{ children: ReactNode }>;
189
214
  }
190
215
 
191
216
  export const UnifiedEventDetail = memo(function UnifiedEventDetail({
192
217
  event,
193
218
  onBack,
219
+ NetworkEventDetailViewComponent,
220
+ networkDetailWrapper,
194
221
  }: UnifiedEventDetailProps) {
195
222
  const sourceConfig = SOURCE_CONFIG[event.source];
196
223
  const timestamp = new Date(event.timestamp);
224
+ // Shared ignored-domain/URL patterns — drives the "Ignore Domain / Ignore URL
225
+ // Pattern" toggles at the bottom of the network detail page so they actually
226
+ // work here and stay in sync with the Network tool.
227
+ const { values: ignoredPatternValues, toggle: toggleIgnoredPattern } =
228
+ useIgnoredPatterns();
197
229
 
198
230
  // For storage events, create a conversation object to use with StorageEventDetailContent
199
231
  const storageConversation = useMemo(() => {
@@ -249,16 +281,28 @@ export const UnifiedEventDetail = memo(function UnifiedEventDetail({
249
281
  );
250
282
  }
251
283
 
252
- // For network events, use the shared NetworkEventDetailView if available
253
- if (event.source === "network" && optionalComponents.NetworkEventDetailView) {
254
- const { NetworkEventDetailView } = optionalComponents;
284
+ // For network events, render the SAME NetworkEventDetailView the Network tool
285
+ // uses. Prefer an injected component (desktop), fall back to the dynamically
286
+ // required one (mobile). Wire the shared ignored-patterns so the bottom
287
+ // "Ignore Domain / Ignore URL Pattern" toggles work, and wrap in the optional
288
+ // body-resolver provider so large bodies can be fetched on demand.
289
+ const NetworkDetailView =
290
+ NetworkEventDetailViewComponent ?? optionalComponents.NetworkEventDetailView;
291
+ if (event.source === "network" && NetworkDetailView) {
255
292
  const networkEvent = event.originalEvent as NetworkEvent;
293
+ const NetworkWrapper = networkDetailWrapper ?? PassThrough;
256
294
 
257
295
  return (
258
296
  <View style={styles.container}>
259
- <ScrollView style={styles.content}>
260
- <NetworkEventDetailView event={networkEvent} />
261
- </ScrollView>
297
+ <NetworkWrapper>
298
+ <ScrollView style={styles.content}>
299
+ <NetworkDetailView
300
+ event={networkEvent}
301
+ ignoredPatterns={ignoredPatternValues}
302
+ onTogglePattern={toggleIgnoredPattern}
303
+ />
304
+ </ScrollView>
305
+ </NetworkWrapper>
262
306
  </View>
263
307
  );
264
308
  }
@@ -6,7 +6,14 @@
6
6
  */
7
7
 
8
8
  import { useState, useEffect, useCallback, useMemo, useRef } from "react";
9
- import { useFeatureGate, subscribeToSubscriberCountChanges, isDevToolsStorageKey } from "@buoy-gg/shared-ui";
9
+ import {
10
+ useFeatureGate,
11
+ subscribeToSubscriberCountChanges,
12
+ isDevToolsStorageKey,
13
+ useIgnoredPatterns,
14
+ isUrlIgnored,
15
+ type IgnoredPattern,
16
+ } from "@buoy-gg/shared-ui";
10
17
  import type { UnifiedEvent, EventSource, SourceInfo } from "../types";
11
18
  import {
12
19
  subscribe,
@@ -26,7 +33,6 @@ import {
26
33
  unsubscribeFromZustand,
27
34
  unsubscribeFromJotai,
28
35
  unsubscribeFromRender,
29
- unsubscribeAll,
30
36
  getSourceCounts,
31
37
  clearEvents as clearStoreEvents,
32
38
  getAvailableEventSources,
@@ -76,6 +82,20 @@ function isBuoyInternalEvent(event: UnifiedEvent): boolean {
76
82
  return false;
77
83
  }
78
84
 
85
+ /**
86
+ * Check whether a network event should be hidden because its URL matches one of
87
+ * the user's shared ignored-domain/URL patterns (the same store the Network tool
88
+ * writes to). Non-network events are never affected.
89
+ */
90
+ function isNetworkEventIgnored(
91
+ event: UnifiedEvent,
92
+ ignoredPatterns: IgnoredPattern[]
93
+ ): boolean {
94
+ if (event.source !== "network" || ignoredPatterns.length === 0) return false;
95
+ const url = (event.originalEvent as { url?: string } | undefined)?.url ?? "";
96
+ return isUrlIgnored(url, ignoredPatterns);
97
+ }
98
+
79
99
  /**
80
100
  * All possible sources for display
81
101
  */
@@ -117,22 +137,6 @@ const SOURCE_TO_EVENT_SOURCES: Record<EventSource, EventSource[]> = {
117
137
  render: ["render"],
118
138
  };
119
139
 
120
- /**
121
- * Map event sources to their parent discovery ID
122
- */
123
- const EVENT_SOURCE_TO_DISCOVERY_ID: Record<EventSource, string> = {
124
- "storage-async": "storage",
125
- "storage-mmkv": "storage",
126
- redux: "redux",
127
- network: "network",
128
- "react-query": "react-query",
129
- "react-query-query": "react-query",
130
- "react-query-mutation": "react-query",
131
- route: "route-events",
132
- zustand: "zustand",
133
- jotai: "jotai",
134
- render: "render",
135
- };
136
140
 
137
141
  export interface UseUnifiedEventsResult {
138
142
  events: UnifiedEvent[];
@@ -160,6 +164,10 @@ export interface UseUnifiedEventsResult {
160
164
 
161
165
  export function useUnifiedEvents(): UseUnifiedEventsResult {
162
166
  const { isPro } = useFeatureGate();
167
+ // Shared ignored-domain/URL patterns (same store as the Network tool). Network
168
+ // events matching these are hidden from the events list too, keeping both
169
+ // tools' filtering perfectly in sync.
170
+ const { patterns: ignoredPatterns } = useIgnoredPatterns();
163
171
  const [events, setEvents] = useState<UnifiedEvent[]>([]);
164
172
  const [enabledSources, setEnabledSources] = useState<Set<EventSource>>(
165
173
  () => new Set(DEFAULT_ENABLED_SOURCES)
@@ -167,10 +175,16 @@ export function useUnifiedEvents(): UseUnifiedEventsResult {
167
175
  const [isCapturing, setIsCapturing] = useState(true);
168
176
  const isStateRestoredRef = useRef(false);
169
177
 
170
- // Get discovered sources (which packages are installed)
171
- const discoveredSources = useMemo(() => {
172
- return getAvailableEventSources();
173
- }, []);
178
+ // Get discovered sources (which packages are installed).
179
+ // Held in state, not a mount-only memo, because in remote mirror mode
180
+ // (desktop dashboard) the device's available sources arrive asynchronously
181
+ // via setRemoteAvailableSources() — after first paint. We refresh this from
182
+ // the store's notify path below so the filter badge bar appears once the
183
+ // first device snapshot lands. The reference is kept stable while the set is
184
+ // unchanged so dependent memos/effects don't re-run on every event.
185
+ const [discoveredSources, setDiscoveredSources] = useState<Set<EventSource>>(
186
+ () => getAvailableEventSources()
187
+ );
174
188
 
175
189
  // Subscribe to subscriber count changes for instant UI updates (TanStack Query pattern)
176
190
  const [subscriberCountVersion, setSubscriberCountVersion] = useState(0);
@@ -189,10 +203,22 @@ export function useUnifiedEvents(): UseUnifiedEventsResult {
189
203
  });
190
204
  }, [discoveredSources]);
191
205
 
192
- // Subscribe to store changes (always subscribe to get events when they come)
206
+ // Subscribe to store changes (always subscribe to get events when they come).
207
+ // Also refresh discoveredSources here: in remote mirror mode the device's
208
+ // availability is set right before replaceEvents() fires this listener, so
209
+ // this is where the badge bar first learns which sources exist. Keep the
210
+ // Set reference stable when membership is unchanged to avoid re-running the
211
+ // availableDisplaySources memo / restore-state effect on every event.
193
212
  useEffect(() => {
194
213
  const unsubscribe = subscribe((newEvents) => {
195
214
  setEvents(newEvents);
215
+ setDiscoveredSources((prev) => {
216
+ const next = getAvailableEventSources();
217
+ if (prev.size === next.size && [...next].every((s) => prev.has(s))) {
218
+ return prev;
219
+ }
220
+ return new Set(next);
221
+ });
196
222
  });
197
223
  return unsubscribe;
198
224
  }, []);
@@ -279,15 +305,19 @@ export function useUnifiedEvents(): UseUnifiedEventsResult {
279
305
  return allowed;
280
306
  }, [enabledSources]);
281
307
 
282
- // Filter events by enabled sources and exclude Buoy internal events
308
+ // Filter events by enabled sources, exclude Buoy internal events, and hide
309
+ // network events matching the shared ignored-domain/URL patterns.
283
310
  const allFilteredEvents = useMemo(() => {
284
311
  if (allowedEventSources.size === 0) {
285
312
  return [];
286
313
  }
287
314
  return events.filter(
288
- (event) => allowedEventSources.has(event.source) && !isBuoyInternalEvent(event)
315
+ (event) =>
316
+ allowedEventSources.has(event.source) &&
317
+ !isBuoyInternalEvent(event) &&
318
+ !isNetworkEventIgnored(event, ignoredPatterns)
289
319
  );
290
- }, [events, allowedEventSources]);
320
+ }, [events, allowedEventSources, ignoredPatterns]);
291
321
 
292
322
  // Apply free tier limit if not Pro
293
323
  const filteredEvents = useMemo(() => {
@@ -456,9 +486,25 @@ export function useUnifiedEvents(): UseUnifiedEventsResult {
456
486
  }, [enabledSources]);
457
487
 
458
488
  const stopCapturing = useCallback(() => {
459
- unsubscribeAll();
489
+ // Release only THIS hook's enabled sources (mirrors startCapturing). Source
490
+ // subscriptions are ref-counted in the store, so this won't yank sources a
491
+ // remote dashboard consumer is still watching (vs the old unsubscribeAll
492
+ // hard reset, which tore down everyone's).
493
+ if (enabledSources.has("storage-async")) unsubscribeFromStorage();
494
+ if (enabledSources.has("redux")) unsubscribeFromRedux();
495
+ if (enabledSources.has("network")) unsubscribeFromNetwork();
496
+ if (
497
+ enabledSources.has("react-query-query") ||
498
+ enabledSources.has("react-query-mutation")
499
+ ) {
500
+ unsubscribeFromReactQuery();
501
+ }
502
+ if (enabledSources.has("route")) unsubscribeFromRoutes();
503
+ if (enabledSources.has("zustand")) unsubscribeFromZustand();
504
+ if (enabledSources.has("jotai")) unsubscribeFromJotai();
505
+ if (enabledSources.has("render")) unsubscribeFromRender();
460
506
  setIsCapturing(false);
461
- }, []);
507
+ }, [enabledSources]);
462
508
 
463
509
  const toggleCapturing = useCallback(() => {
464
510
  if (isCapturing) {
package/src/index.tsx CHANGED
@@ -19,6 +19,12 @@ export { eventsToolPreset, createEventsTool } from "./preset";
19
19
  // MAIN COMPONENT
20
20
  // =============================================================================
21
21
  export { UnifiedEventViewer } from "./components/UnifiedEventViewer";
22
+ export { EventsModal } from "./components/EventsModal";
23
+
24
+ // =============================================================================
25
+ // EXTERNAL SYNC (Adapter for @buoy-gg/external-sync's useExternalSync)
26
+ // =============================================================================
27
+ export { eventsSyncAdapter } from "./sync/eventsSyncAdapter";
22
28
 
23
29
  // =============================================================================
24
30
  // HOOKS (For consuming event data)
@@ -102,8 +108,10 @@ export {
102
108
  // These are NOT exported to prevent users from bypassing the unified event
103
109
  // system. Event subscription should go through the proper tool integration.
104
110
  // =============================================================================
111
+ /** @internal - Remote mirror mode only (desktop dashboard): replaceEvents /
112
+ * onClear / setRemoteAvailableSources. Do not use for event subscription. */
113
+ export { unifiedEventStore } from "./stores/unifiedEventStore";
105
114
  // NOTE: The following are intentionally NOT exported:
106
- // - unifiedEventStore (internal store)
107
115
  // - subscribeToStorage, unsubscribeFromStorage (internal subscription)
108
116
  // - subscribeToRedux, unsubscribeFromRedux (internal subscription)
109
117
  // - subscribeToNetwork, unsubscribeFromNetwork (internal subscription)
@@ -0,0 +1,25 @@
1
+ import type { EventSource } from "../types";
2
+
3
+ /**
4
+ * Map an event source (the granular UI badge identity) to its parent discovery
5
+ * ID (the key used by unifiedEventStore's source subscriptions). Several event
6
+ * sources can share one discovery source — e.g. storage-async + storage-mmkv
7
+ * both come from the "storage" discovery source, and the react-query family all
8
+ * map to "react-query".
9
+ *
10
+ * Shared by useUnifiedEvents (the on-device consumer) and unifiedEventStore's
11
+ * remote-consumer channel so the mapping lives in exactly one place.
12
+ */
13
+ export const EVENT_SOURCE_TO_DISCOVERY_ID: Record<EventSource, string> = {
14
+ "storage-async": "storage",
15
+ "storage-mmkv": "storage",
16
+ redux: "redux",
17
+ network: "network",
18
+ "react-query": "react-query",
19
+ "react-query-query": "react-query",
20
+ "react-query-mutation": "react-query",
21
+ route: "route-events",
22
+ zustand: "zustand",
23
+ jotai: "jotai",
24
+ render: "render",
25
+ };
@@ -18,16 +18,42 @@ import {
18
18
  type DiscoveredEventSource,
19
19
  type AggregatedSubscriberCounts,
20
20
  } from "../utils/autoDiscoverEventSources";
21
+ import { EVENT_SOURCE_TO_DISCOVERY_ID } from "../sources/sourceIds";
21
22
 
22
23
  const MAX_EVENTS = 200;
23
24
 
25
+ /**
26
+ * Source IDs that subscribeToAll() must NOT auto-subscribe to.
27
+ *
28
+ * These are high-frequency sources the UI excludes from DEFAULT_ENABLED_SOURCES
29
+ * (see useUnifiedEvents.ts). "render" (highlight-updates) fires on every
30
+ * component render — tens of events per second — which floods the shared store
31
+ * (capped at MAX_EVENTS), shoves real events out, and shows up as hidden noise
32
+ * ("1 of 100"). subscribeToAll() is the dashboard-watch path; it must mirror
33
+ * the on-device default of leaving render OFF unless the user opts in.
34
+ */
35
+ const SUBSCRIBE_ALL_EXCLUDED_SOURCE_IDS = new Set<string>(["render"]);
36
+
24
37
  class UnifiedEventStore {
25
38
  private events: UnifiedEvent[] = [];
26
39
  private listeners: Set<UnifiedEventListener> = new Set();
27
40
  private activeSources: Set<EventSource> = new Set();
28
-
29
- // Track which sources are currently subscribed
30
- private sourceUnsubscribers: Map<string, () => void> = new Map();
41
+ private clearListeners: Set<() => void> = new Set();
42
+ private remoteAvailableSources: Set<EventSource> | null = null;
43
+
44
+ // Track which sources are currently subscribed, ref-counted so independent
45
+ // consumers (the on-device EventsModal and the remote dashboard) can each
46
+ // request a source and the underlying capture only stops when the LAST one
47
+ // drops it. Presence of a key still means "subscribed".
48
+ private sourceUnsubscribers: Map<
49
+ string,
50
+ { unsubscribe: () => void; refCount: number }
51
+ > = new Map();
52
+
53
+ // Discovery IDs the remote dashboard (sync adapter) currently wants. Tracked
54
+ // separately so the remote consumer can be diffed/cleared independently of
55
+ // the on-device consumer's subscriptions.
56
+ private remoteDiscoveryIds: Set<string> = new Set();
31
57
 
32
58
  // Map network event IDs to unified event IDs for updates
33
59
  private networkEventIdMap: Map<string, string> = new Map();
@@ -40,12 +66,66 @@ class UnifiedEventStore {
40
66
  }
41
67
 
42
68
  /**
43
- * Get which event source types are available
69
+ * Get which event source types are available. In remote mirror mode this
70
+ * reflects the synced device's availability instead of local discovery.
44
71
  */
45
72
  getAvailableEventSources(): Set<EventSource> {
73
+ if (this.remoteAvailableSources) {
74
+ return this.remoteAvailableSources;
75
+ }
46
76
  return getCachedDiscovery().availableEventSources;
47
77
  }
48
78
 
79
+ // ===========================================================================
80
+ // REMOTE MIRROR MODE
81
+ // ===========================================================================
82
+
83
+ /**
84
+ * Override source availability with the synced device's (auto-discovery
85
+ * finds nothing on the dashboard — the tool packages live on the device).
86
+ * Pass null to restore local discovery.
87
+ *
88
+ * Notifies listeners when the set actually changes so consumers
89
+ * (useUnifiedEvents) can refresh their available-source list — this is the
90
+ * only signal the dashboard's filter badge bar gets when capture is OFF and
91
+ * no events are flowing to trigger replaceEvents().
92
+ */
93
+ setRemoteAvailableSources(sources: EventSource[] | null): void {
94
+ const next = sources ? new Set(sources) : null;
95
+ const prev = this.remoteAvailableSources;
96
+ const unchanged =
97
+ (prev === null && next === null) ||
98
+ (!!prev &&
99
+ !!next &&
100
+ prev.size === next.size &&
101
+ [...next].every((s) => prev.has(s)));
102
+ this.remoteAvailableSources = next;
103
+ if (!unchanged) {
104
+ this.notifyListeners();
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Replace the entire event list and notify listeners. Used in remote
110
+ * mirror mode where full snapshots arrive from a synced device.
111
+ */
112
+ replaceEvents(events: UnifiedEvent[]): void {
113
+ this.events = events.slice(0, MAX_EVENTS);
114
+ this.activeSources = new Set(events.map((event) => event.source));
115
+ this.notifyListeners();
116
+ }
117
+
118
+ /**
119
+ * Listen for clearEvents() calls. Used in remote mirror mode to forward a
120
+ * clear performed in the dashboard UI to the synced device.
121
+ */
122
+ onClear(listener: () => void): () => void {
123
+ this.clearListeners.add(listener);
124
+ return () => {
125
+ this.clearListeners.delete(listener);
126
+ };
127
+ }
128
+
49
129
  /**
50
130
  * Subscribe to all available event sources
51
131
  */
@@ -53,6 +133,12 @@ class UnifiedEventStore {
53
133
  const { sources } = getCachedDiscovery();
54
134
 
55
135
  for (const source of sources) {
136
+ // Skip high-frequency sources the UI leaves off by default (e.g. render).
137
+ // Subscribing to them here would flood the timeline with events that are
138
+ // then filtered out of the display — see SUBSCRIBE_ALL_EXCLUDED_SOURCE_IDS.
139
+ if (SUBSCRIBE_ALL_EXCLUDED_SOURCE_IDS.has(source.id)) {
140
+ continue;
141
+ }
56
142
  await this.subscribeToSource(source);
57
143
  }
58
144
  }
@@ -61,10 +147,18 @@ class UnifiedEventStore {
61
147
  * Subscribe to a specific event source by ID
62
148
  */
63
149
  async subscribeToSource(source: DiscoveredEventSource): Promise<void> {
64
- if (this.sourceUnsubscribers.has(source.id)) {
65
- return; // Already subscribed
150
+ const existing = this.sourceUnsubscribers.get(source.id);
151
+ if (existing) {
152
+ existing.refCount++; // Another consumer wants it — just count.
153
+ return;
66
154
  }
67
155
 
156
+ // Reserve the slot synchronously (refCount 1) so concurrent subscribe calls
157
+ // for the same source ref-count instead of double-subscribing during the
158
+ // awaits below.
159
+ const entry = { unsubscribe: () => {}, refCount: 1 };
160
+ this.sourceUnsubscribers.set(source.id, entry);
161
+
68
162
  try {
69
163
  // Run setup if needed
70
164
  if (source.setup) {
@@ -77,20 +171,27 @@ class UnifiedEventStore {
77
171
  });
78
172
 
79
173
  if (unsubscribe) {
80
- this.sourceUnsubscribers.set(source.id, unsubscribe);
174
+ entry.unsubscribe = unsubscribe;
81
175
  }
82
176
  } catch {
83
- // Silently fail - source may not be available
177
+ // Failed - source may not be available. Drop the reservation unless other
178
+ // consumers incremented it while we were awaiting.
179
+ if (this.sourceUnsubscribers.get(source.id) === entry && entry.refCount <= 1) {
180
+ this.sourceUnsubscribers.delete(source.id);
181
+ }
84
182
  }
85
183
  }
86
184
 
87
185
  /**
88
- * Unsubscribe from a specific source by ID
186
+ * Unsubscribe from a specific source by ID. Ref-counted: the real teardown
187
+ * only runs when the last consumer drops it.
89
188
  */
90
189
  unsubscribeFromSource(sourceId: string): void {
91
- const unsubscribe = this.sourceUnsubscribers.get(sourceId);
92
- if (unsubscribe) {
93
- unsubscribe();
190
+ const entry = this.sourceUnsubscribers.get(sourceId);
191
+ if (!entry) return;
192
+ entry.refCount--;
193
+ if (entry.refCount <= 0) {
194
+ entry.unsubscribe();
94
195
  this.sourceUnsubscribers.delete(sourceId);
95
196
  }
96
197
  }
@@ -341,6 +442,13 @@ class UnifiedEventStore {
341
442
  this.activeSources.clear();
342
443
  this.networkEventIdMap.clear();
343
444
  this.notifyListeners();
445
+ this.clearListeners.forEach((listener) => {
446
+ try {
447
+ listener();
448
+ } catch {
449
+ // Ignore listener errors
450
+ }
451
+ });
344
452
  }
345
453
 
346
454
  /**
@@ -377,14 +485,87 @@ class UnifiedEventStore {
377
485
  * Unsubscribe from all sources
378
486
  */
379
487
  unsubscribeAll(): void {
380
- // Unsubscribe from all tracked sources
381
- for (const [, unsubscribe] of this.sourceUnsubscribers) {
382
- unsubscribe();
488
+ // Hard reset: tear down every tracked source regardless of ref-count.
489
+ for (const [, entry] of this.sourceUnsubscribers) {
490
+ entry.unsubscribe();
383
491
  }
384
492
  this.sourceUnsubscribers.clear();
385
493
 
386
- // Clear active sources
494
+ // Clear active + remote source bookkeeping (all subscriptions are gone).
387
495
  this.activeSources.clear();
496
+ this.remoteDiscoveryIds.clear();
497
+ }
498
+
499
+ /**
500
+ * Remote-consumer channel: the dashboard sync adapter declares which event
501
+ * sources it wants the device to capture. Diffed against the previous remote
502
+ * set so only changed sources are (un)subscribed; ref-counting keeps the
503
+ * on-device EventsModal's own subscriptions intact. Pass "all" to capture
504
+ * every discovered source except the high-frequency excluded ones.
505
+ */
506
+ async setRemoteEnabledSources(
507
+ sources: EventSource[] | "all"
508
+ ): Promise<void> {
509
+ const { sources: discovered } = getCachedDiscovery();
510
+
511
+ let nextIds: Set<string>;
512
+ if (sources === "all") {
513
+ nextIds = new Set(
514
+ discovered
515
+ .map((s) => s.id)
516
+ .filter((id) => !SUBSCRIBE_ALL_EXCLUDED_SOURCE_IDS.has(id))
517
+ );
518
+ } else {
519
+ nextIds = new Set<string>();
520
+ for (const src of sources) {
521
+ const id = EVENT_SOURCE_TO_DISCOVERY_ID[src];
522
+ if (id) nextIds.add(id);
523
+ }
524
+ }
525
+
526
+ // Swap the tracked set SYNCHRONOUSLY (before the awaits) so a watch's
527
+ // ensureRemoteSourcesDefault() that runs in between sees the new selection
528
+ // and won't re-broaden to "all".
529
+ const prevIds = this.remoteDiscoveryIds;
530
+ this.remoteDiscoveryIds = nextIds;
531
+
532
+ // Subscribe newly-requested sources.
533
+ for (const id of nextIds) {
534
+ if (!prevIds.has(id)) {
535
+ const source = discovered.find((s) => s.id === id);
536
+ if (source) await this.subscribeToSource(source);
537
+ }
538
+ }
539
+ // Release sources the remote no longer wants.
540
+ for (const id of prevIds) {
541
+ if (!nextIds.has(id)) {
542
+ this.unsubscribeFromSource(id);
543
+ }
544
+ }
545
+ }
546
+
547
+ /**
548
+ * Called by the sync adapter when a dashboard starts watching: default to all
549
+ * sources, but ONLY if the dashboard hasn't already declared a selection for
550
+ * this watch session (the setEnabledSources action can arrive before the
551
+ * watch). Keeps backward compatibility with dashboards that never narrow.
552
+ */
553
+ ensureRemoteSourcesDefault(): void {
554
+ if (this.remoteDiscoveryIds.size === 0) {
555
+ void this.setRemoteEnabledSources("all");
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Release all of the remote consumer's source subscriptions (e.g. when the
561
+ * dashboard stops watching the events tool). Ref-counting means sources the
562
+ * on-device EventsModal also wants stay subscribed.
563
+ */
564
+ clearRemoteSources(): void {
565
+ for (const id of this.remoteDiscoveryIds) {
566
+ this.unsubscribeFromSource(id);
567
+ }
568
+ this.remoteDiscoveryIds.clear();
388
569
  }
389
570
 
390
571
  /**
@@ -0,0 +1,56 @@
1
+ import { unifiedEventStore } from "../stores/unifiedEventStore";
2
+ import type { EventSource } from "../types";
3
+
4
+ /**
5
+ * Sync adapter for the events tool, consumed by @buoy-gg/external-sync's
6
+ * `useExternalSync` (structurally matches its ToolSyncAdapter interface so
7
+ * this package doesn't need a dependency on it).
8
+ *
9
+ * This adapter is the "remote consumer" of the unified event store. While a
10
+ * dashboard is watching, it requests a SET of event sources (defaulting to all,
11
+ * narrowed by the dashboard via `setEnabledSources`). On unwatch it releases
12
+ * those requests, so — unlike before — the device stops capturing sources that
13
+ * nothing else is watching. Source requests are ref-counted in the store, so
14
+ * the on-device EventsModal keeps working when open at the same time.
15
+ *
16
+ * The snapshot carries the device's available sources so the dashboard can
17
+ * render the source filter chips (its own auto-discovery finds nothing — the
18
+ * tool packages live on the device).
19
+ */
20
+ export const eventsSyncAdapter = {
21
+ version: 2,
22
+ getSnapshot: () => ({
23
+ events: unifiedEventStore.getEvents(),
24
+ availableSources: Array.from(unifiedEventStore.getAvailableEventSources()),
25
+ }),
26
+ subscribe: (onChange: () => void) => {
27
+ // Default to all sources until the dashboard narrows the set via
28
+ // setEnabledSources — backward compatible with dashboards that don't send
29
+ // it. Uses ensureRemoteSourcesDefault so a setEnabledSources that raced
30
+ // ahead of this watch isn't clobbered back to "all".
31
+ unifiedEventStore.ensureRemoteSourcesDefault();
32
+ const unsubscribe = unifiedEventStore.subscribe(() => onChange());
33
+ return () => {
34
+ unsubscribe();
35
+ // Release this consumer's source subscriptions so the device stops
36
+ // capturing sources nothing else is watching (fixes the old leak).
37
+ unifiedEventStore.clearRemoteSources();
38
+ };
39
+ },
40
+ actions: {
41
+ clearEvents: () => {
42
+ unifiedEventStore.clearEvents();
43
+ },
44
+ /**
45
+ * Narrow which sources the device captures for this dashboard, driven by
46
+ * the dashboard's source badges. Ref-counted in the store, so disabling a
47
+ * source here only stops its capture if no other consumer wants it.
48
+ */
49
+ setEnabledSources: (params: unknown) => {
50
+ const sources =
51
+ (params as { sources?: EventSource[] } | undefined)?.sources ?? [];
52
+ unifiedEventStore.setRemoteEnabledSources(sources);
53
+ return { enabledSources: sources };
54
+ },
55
+ },
56
+ };