yaver-feedback-react-native 0.3.0 → 0.5.0

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.
@@ -3,6 +3,14 @@ import { YaverDiscovery } from './Discovery';
3
3
  import { BlackBox } from './BlackBox';
4
4
  import { P2PClient } from './P2PClient';
5
5
  import { ShakeDetector } from './ShakeDetector';
6
+ import {
7
+ configureAuthEndpoints,
8
+ getToken,
9
+ getSelectedDeviceId,
10
+ clearToken,
11
+ clearSelectedDeviceId,
12
+ DEFAULT_CONVEX_SITE_URL,
13
+ } from './auth';
6
14
 
7
15
  let config: FeedbackConfig | null = null;
8
16
  let enabled = false;
@@ -16,6 +24,13 @@ let maxErrors = 5;
16
24
  /** Track whether BlackBox was running before disable (to restart on enable). */
17
25
  let blackBoxWasStreaming = false;
18
26
 
27
+ /**
28
+ * Flag evaluation cache — 30s TTL per `userId|key`. Prevents a
29
+ * tight render loop from hammering /flags/eval when the dev calls
30
+ * `YaverFeedback.getFlag()` every frame.
31
+ */
32
+ const flagCache: Map<string, { value: unknown; at: number }> = new Map();
33
+
19
34
  /**
20
35
  * Main entry point for the Yaver Feedback SDK.
21
36
  * Call `YaverFeedback.init()` once at app startup.
@@ -34,9 +49,23 @@ export class YaverFeedback {
34
49
  maxRecordingDuration: 120,
35
50
  feedbackMode: 'batch',
36
51
  agentCommentaryLevel: 0,
52
+ autoLogin: true,
37
53
  ...cfg,
38
54
  };
39
55
 
56
+ // Route the in-SDK login screen to prod yaver.io by default; callers may
57
+ // override for staging via authConvexSiteUrl / authWebBaseUrl.
58
+ configureAuthEndpoints({
59
+ convexSiteUrl: cfg.authConvexSiteUrl,
60
+ webBaseUrl: cfg.authWebBaseUrl,
61
+ });
62
+ // If no explicit convexUrl was set but we have an auth site URL, use it
63
+ // so Discovery.discoverFromConvex() has somewhere to look up the user's
64
+ // machines (works for both LAN-direct and off-LAN relay paths).
65
+ if (!config.convexUrl) {
66
+ config.convexUrl = cfg.authConvexSiteUrl ?? DEFAULT_CONVEX_SITE_URL;
67
+ }
68
+
40
69
  // Default: enabled only in dev mode
41
70
  if (cfg.enabled !== undefined) {
42
71
  enabled = cfg.enabled;
@@ -44,11 +73,22 @@ export class YaverFeedback {
44
73
  enabled = typeof __DEV__ !== 'undefined' ? __DEV__ : false;
45
74
  }
46
75
 
76
+ // Hydrate cached auth token + preferred device from AsyncStorage so the
77
+ // SDK reconnects silently on subsequent launches. If autoLogin is false
78
+ // the caller is responsible for providing authToken themselves.
79
+ if (config.autoLogin !== false && enabled) {
80
+ void YaverFeedback.hydrateSession();
81
+ }
82
+
47
83
  // Create P2P client if we have a URL
48
84
  if (config.agentUrl) {
49
- p2pClient = new P2PClient(config.agentUrl, config.authToken);
85
+ p2pClient = new P2PClient(config.agentUrl, config.authToken ?? '');
50
86
  } else {
51
87
  p2pClient = null;
88
+ // Auto-discover agent in the background when convexUrl or LAN is available
89
+ if (enabled && (config.authToken || config.preferredDeviceId)) {
90
+ YaverFeedback.discoverAgent();
91
+ }
52
92
  }
53
93
 
54
94
  // Set up error capture buffer size
@@ -80,21 +120,15 @@ export class YaverFeedback {
80
120
  if (cfg.onReload) {
81
121
  cfg.onReload();
82
122
  } else {
83
- // Default: try DevSettings.reload() in dev mode
84
- try {
85
- const { DevSettings } = require('react-native');
86
- if (typeof DevSettings?.reload === 'function') {
87
- DevSettings.reload();
88
- }
89
- } catch {
90
- // Not in dev mode or DevSettings unavailable
91
- }
123
+ YaverFeedback.defaultReload();
92
124
  }
93
125
  } else if (cmd.command === 'reload_bundle' && cmd.data) {
94
126
  const bundleUrl = cmd.data.bundleUrl as string;
95
127
  const assetsUrl = cmd.data.assetsUrl as string | undefined;
96
128
  if (cfg.onReloadBundle) {
97
129
  cfg.onReloadBundle(bundleUrl, assetsUrl);
130
+ } else {
131
+ YaverFeedback.defaultReloadBundle(bundleUrl, assetsUrl);
98
132
  }
99
133
  }
100
134
  });
@@ -111,6 +145,127 @@ export class YaverFeedback {
111
145
  // pass-through wrapper they insert into their own error chain
112
146
  }
113
147
 
148
+ /**
149
+ * Run agent discovery in the background.
150
+ * Called automatically from init() when no agentUrl is provided.
151
+ * Sets config.agentUrl and creates P2PClient on success.
152
+ */
153
+ static async discoverAgent(): Promise<void> {
154
+ if (!config || !enabled) return;
155
+ if (config.agentUrl) return; // already have a URL
156
+ if (!config.authToken) return; // need auth before discovery can succeed
157
+
158
+ try {
159
+ const result = await YaverDiscovery.discover({
160
+ convexUrl: config.convexUrl,
161
+ authToken: config.authToken,
162
+ preferredDeviceId: config.preferredDeviceId,
163
+ });
164
+ if (result && config) {
165
+ config.agentUrl = result.url;
166
+ p2pClient = new P2PClient(result.url, config.authToken ?? '');
167
+ }
168
+ } catch {
169
+ // Discovery failed — FloatingButton will show disconnected, user can retry
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Pull a cached session token + selected device from AsyncStorage (populated
175
+ * by the in-SDK login + machine-picker screens). When present the SDK can
176
+ * reconnect silently on launch without re-prompting the user. Safe to call
177
+ * multiple times — it only overrides values the caller did not already set.
178
+ */
179
+ static async hydrateSession(): Promise<void> {
180
+ if (!config) return;
181
+ try {
182
+ if (!config.authToken) {
183
+ const cached = await getToken();
184
+ if (cached) {
185
+ config.authToken = cached;
186
+ }
187
+ }
188
+ if (!config.preferredDeviceId) {
189
+ const cachedDevice = await getSelectedDeviceId();
190
+ if (cachedDevice) {
191
+ config.preferredDeviceId = cachedDevice;
192
+ }
193
+ }
194
+ if (config.authToken && !config.agentUrl) {
195
+ await YaverFeedback.discoverAgent();
196
+ }
197
+ } catch {
198
+ // hydration best-effort
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Update the signed-in session token (e.g. after the in-SDK login screen
204
+ * succeeds). Rebuilds the P2P client and kicks off agent discovery.
205
+ */
206
+ static async setAuthToken(token: string): Promise<void> {
207
+ if (!config) return;
208
+ config.authToken = token;
209
+ if (config.agentUrl) {
210
+ p2pClient = new P2PClient(config.agentUrl, token);
211
+ } else {
212
+ await YaverFeedback.discoverAgent();
213
+ }
214
+ }
215
+
216
+ /** Returns true once the SDK has a session token it can use. */
217
+ static isAuthed(): boolean {
218
+ return Boolean(config?.authToken);
219
+ }
220
+
221
+ /**
222
+ * Request the embedded FeedbackModal to show the login screen. Works by
223
+ * emitting an event the modal listens for — avoids forcing the host app
224
+ * to mount a second navigator.
225
+ */
226
+ static showLogin(): void {
227
+ const { DeviceEventEmitter } = require('react-native');
228
+ DeviceEventEmitter.emit('yaverFeedback:startLogin');
229
+ }
230
+
231
+ /**
232
+ * Request the embedded FeedbackModal to show the machine picker. Requires
233
+ * an active session; no-ops otherwise.
234
+ */
235
+ static showMachinePicker(): void {
236
+ if (!YaverFeedback.isAuthed()) return;
237
+ const { DeviceEventEmitter } = require('react-native');
238
+ DeviceEventEmitter.emit('yaverFeedback:startMachinePicker');
239
+ }
240
+
241
+ /**
242
+ * Update the selected remote device. Resets the cached agent URL so the
243
+ * next `startReport()` (or FloatingButton press) rediscovers against the
244
+ * newly-selected machine.
245
+ */
246
+ static async setPreferredDevice(deviceId: string): Promise<void> {
247
+ if (!config) return;
248
+ config.preferredDeviceId = deviceId;
249
+ config.agentUrl = undefined;
250
+ p2pClient = null;
251
+ await YaverFeedback.discoverAgent();
252
+ }
253
+
254
+ /**
255
+ * Sign out: clear cached token + device, tear down the P2P client. The
256
+ * SDK stays enabled; the next feedback trigger will re-prompt for login.
257
+ */
258
+ static async signOut(): Promise<void> {
259
+ await clearToken();
260
+ await clearSelectedDeviceId();
261
+ if (config) {
262
+ config.authToken = undefined;
263
+ config.preferredDeviceId = undefined;
264
+ config.agentUrl = undefined;
265
+ }
266
+ p2pClient = null;
267
+ }
268
+
114
269
  /**
115
270
  * Manually trigger the feedback collection flow.
116
271
  * Opens the feedback modal if the SDK is initialized and enabled.
@@ -126,6 +281,18 @@ export class YaverFeedback {
126
281
  return;
127
282
  }
128
283
 
284
+ // If the caller has autoLogin enabled and we have no session yet, show
285
+ // the in-SDK login flow instead of a failing discovery + warning spam.
286
+ if (!config.authToken) {
287
+ if (config.autoLogin !== false) {
288
+ await YaverFeedback.hydrateSession();
289
+ }
290
+ if (!config.authToken) {
291
+ YaverFeedback.showLogin();
292
+ return;
293
+ }
294
+ }
295
+
129
296
  // Auto-discover if no agent URL was provided
130
297
  if (!config.agentUrl) {
131
298
  try {
@@ -136,9 +303,15 @@ export class YaverFeedback {
136
303
  });
137
304
  if (result) {
138
305
  config.agentUrl = result.url;
139
- p2pClient = new P2PClient(result.url, config.authToken);
306
+ p2pClient = new P2PClient(result.url, config.authToken ?? '');
307
+ } else if (config.autoLogin !== false && !config.preferredDeviceId) {
308
+ // No agent discovered and no device picked yet — prompt the user
309
+ // to pick one of their machines (handles the non-LAN case where
310
+ // relay discovery requires knowing which deviceId to target).
311
+ YaverFeedback.showMachinePicker();
312
+ return;
140
313
  } else {
141
- console.warn('[YaverFeedback] No agent found. Set agentUrl, convexUrl, or ensure agent is running on the network.');
314
+ console.warn('[YaverFeedback] No agent found. Check that `yaver serve` is running on the selected machine.');
142
315
  }
143
316
  } catch (err) {
144
317
  console.warn('[YaverFeedback] Auto-discovery failed:', err);
@@ -292,6 +465,103 @@ export class YaverFeedback {
292
465
  return config?.agentCommentaryLevel ?? 0;
293
466
  }
294
467
 
468
+ // ─── One-stop SaaS replacement methods ─────────────────────────
469
+ //
470
+ // These are the three solo-dev SaaS-replacement entry points
471
+ // wired into YaverFeedback so there's exactly one import path
472
+ // for the dev's app code: track / getFlag / checkUpdate.
473
+
474
+ /**
475
+ * Record a business event. Routes through BlackBox so the agent
476
+ * persists it to the analytics ledger (no dashboards — export
477
+ * via CSV or webhook into PostHog).
478
+ *
479
+ * @example
480
+ * ```ts
481
+ * YaverFeedback.track('purchase_completed', { amount: '9.99' });
482
+ * ```
483
+ */
484
+ static track(name: string, props?: Record<string, unknown>, route?: string): void {
485
+ if (!enabled) return;
486
+ BlackBox.track(name, props, route);
487
+ }
488
+
489
+ /**
490
+ * Evaluate a single feature flag for a user. Results are cached
491
+ * for 30 seconds inside YaverFeedback so a tight loop evaluating
492
+ * the same key doesn't hammer the agent.
493
+ *
494
+ * @param key — flag key (must exist on the agent)
495
+ * @param defaultValue — returned if the flag is missing / offline
496
+ * @param userId — stable user identifier for rollout bucketing
497
+ */
498
+ static async getFlag<T = boolean | string>(
499
+ key: string,
500
+ defaultValue: T,
501
+ userId: string = 'anonymous',
502
+ ): Promise<T> {
503
+ if (!enabled || !p2pClient) return defaultValue;
504
+ const cacheKey = `${userId}|${key}`;
505
+ const now = Date.now();
506
+ const cached = flagCache.get(cacheKey);
507
+ if (cached && now - cached.at < 30_000) {
508
+ return (cached.value as T) ?? defaultValue;
509
+ }
510
+ try {
511
+ const val = await p2pClient.flagsEvaluateOne<T>(key, userId);
512
+ flagCache.set(cacheKey, { value: val ?? defaultValue, at: now });
513
+ return (val as T) ?? defaultValue;
514
+ } catch {
515
+ return defaultValue;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Bulk evaluate every flag for a user. Cached on the same 30s
521
+ * window as getFlag — use this when boot needs a handful of
522
+ * flags in one roundtrip.
523
+ */
524
+ static async getFlags(
525
+ userId: string = 'anonymous',
526
+ ): Promise<Record<string, unknown>> {
527
+ if (!enabled || !p2pClient) return {};
528
+ const cacheKey = `all|${userId}`;
529
+ const now = Date.now();
530
+ const cached = flagCache.get(cacheKey);
531
+ if (cached && now - cached.at < 30_000) {
532
+ return (cached.value as Record<string, unknown>) ?? {};
533
+ }
534
+ try {
535
+ const flags = await p2pClient.flagsEvaluate(userId);
536
+ flagCache.set(cacheKey, { value: flags, at: now });
537
+ return flags;
538
+ } catch {
539
+ return {};
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Ask what bundle this device should run. Returns the latest
545
+ * release manifest in the configured channel plus a rollout
546
+ * gate. The dev can then compare against what's currently
547
+ * running and prompt the user to reload.
548
+ *
549
+ * On-disk bundle swap is platform-specific — see
550
+ * `YaverFeedback.onUpdateAvailable` if you want a hook.
551
+ */
552
+ static async checkUpdate(
553
+ channel: string = 'production',
554
+ deviceId?: string,
555
+ ): Promise<Awaited<ReturnType<P2PClient['releasesLatest']>>> {
556
+ if (!enabled || !p2pClient) return null;
557
+ return p2pClient.releasesLatest(channel, deviceId);
558
+ }
559
+
560
+ /** Clear the in-memory flag cache. Useful for tests or after sign-out. */
561
+ static clearFlagCache(): void {
562
+ flagCache.clear();
563
+ }
564
+
295
565
  /**
296
566
  * Reporting-only mode: auto-capture screenshot + errors and send
297
567
  * to the agent's /feedback endpoint. No modal UI — just shake and go.
@@ -312,7 +582,7 @@ export class YaverFeedback {
312
582
  });
313
583
  if (result) {
314
584
  config.agentUrl = result.url;
315
- p2pClient = new P2PClient(result.url, config.authToken);
585
+ p2pClient = new P2PClient(result.url, config.authToken ?? '');
316
586
  }
317
587
  } catch {}
318
588
  }
@@ -353,13 +623,92 @@ export class YaverFeedback {
353
623
  errors: errorBuffer.length > 0 ? [...errorBuffer] : undefined,
354
624
  };
355
625
 
356
- await uploadFeedback(config.agentUrl, config.authToken, bundle);
626
+ await uploadFeedback(config.agentUrl, config.authToken ?? '', bundle);
357
627
  console.log('[YaverFeedback] Auto-report sent');
358
628
  } catch (err) {
359
629
  console.warn('[YaverFeedback] Auto-report failed:', err);
360
630
  }
361
631
  }
362
632
 
633
+ /**
634
+ * Default reload handler. Tries three strategies in order:
635
+ *
636
+ * 1. **YaverBundleLoader** — running inside Yaver's native container.
637
+ * Pulls fresh Hermes bundle from agent and reloads the RN bridge.
638
+ *
639
+ * 2. **YaverHotReload** — standalone app with feedback SDK's native module
640
+ * (added via Expo config plugin). Downloads Hermes bundle from agent,
641
+ * saves to Documents, and reloads the RN bridge.
642
+ *
643
+ * 3. **DevSettings.reload()** — standalone dev build connected to Metro.
644
+ */
645
+ private static defaultReload(): void {
646
+ if (!config?.agentUrl) return;
647
+ const bundleUrl = `${config.agentUrl}/dev/native-bundle`;
648
+ const headers = { Authorization: `Bearer ${config.authToken ?? ''}` };
649
+ YaverFeedback.loadBundleAndReload(bundleUrl, headers);
650
+ }
651
+
652
+ /**
653
+ * Default reload_bundle handler. Receives a compiled Hermes bundle URL
654
+ * from the agent and loads it via the best available native mechanism.
655
+ */
656
+ private static defaultReloadBundle(bundleUrl: string, _assetsUrl?: string): void {
657
+ if (!config?.agentUrl) return;
658
+
659
+ const fullUrl = bundleUrl.startsWith('http')
660
+ ? bundleUrl
661
+ : `${config.agentUrl}${bundleUrl}`;
662
+ const headers = { Authorization: `Bearer ${config.authToken ?? ''}` };
663
+ YaverFeedback.loadBundleAndReload(fullUrl, headers);
664
+ }
665
+
666
+ /**
667
+ * Core bundle reload logic. Tries native loaders in order:
668
+ *
669
+ * 1. YaverBundleLoader (Yaver container — full validation + bridge reload)
670
+ * 2. YaverHotReload (SDK's own native module — download + bridge reload)
671
+ * 3. DevSettings.reload() (Metro dev server fallback)
672
+ */
673
+ private static loadBundleAndReload(
674
+ bundleUrl: string,
675
+ headers: Record<string, string>,
676
+ ): void {
677
+ const { NativeModules } = require('react-native');
678
+
679
+ // Strategy 1: YaverBundleLoader (running inside Yaver container)
680
+ if (NativeModules.YaverBundleLoader) {
681
+ NativeModules.YaverBundleLoader.loadBundle(bundleUrl, 'main', headers)
682
+ .catch((err: Error) => {
683
+ console.warn('[YaverFeedback] YaverBundleLoader reload failed:', err);
684
+ });
685
+ return;
686
+ }
687
+
688
+ // Strategy 2: YaverHotReload (SDK's native module, added by Expo config plugin)
689
+ if (NativeModules.YaverHotReload) {
690
+ NativeModules.YaverHotReload.loadBundle(bundleUrl, headers)
691
+ .catch((err: Error) => {
692
+ console.warn('[YaverFeedback] YaverHotReload reload failed:', err);
693
+ });
694
+ return;
695
+ }
696
+
697
+ // Strategy 3: DevSettings.reload() for Metro dev builds
698
+ console.warn(
699
+ '[YaverFeedback] No native bundle loader available. ' +
700
+ 'Add "yaver-feedback-react-native" to your app.json plugins to enable hot reload.',
701
+ );
702
+ try {
703
+ const { DevSettings } = require('react-native');
704
+ if (typeof DevSettings?.reload === 'function') {
705
+ DevSettings.reload();
706
+ }
707
+ } catch {
708
+ // Not in dev mode
709
+ }
710
+ }
711
+
363
712
  /** Tear down the SDK (stop shake detector, clear state). */
364
713
  static destroy(): void {
365
714
  if (shakeDetector) {