yaver-feedback-react-native 0.2.0 → 0.4.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.
package/src/Discovery.ts CHANGED
@@ -1,4 +1,14 @@
1
- import AsyncStorage from '@react-native-async-storage/async-storage';
1
+ // AsyncStorage is an optional peer dep — gracefully degrade if missing
2
+ let AsyncStorage: {
3
+ getItem: (key: string) => Promise<string | null>;
4
+ setItem: (key: string, value: string) => Promise<void>;
5
+ removeItem: (key: string) => Promise<void>;
6
+ } | null = null;
7
+ try {
8
+ AsyncStorage = require('@react-native-async-storage/async-storage').default;
9
+ } catch {
10
+ // Not installed — discovery caching disabled, auto-discovery still works
11
+ }
2
12
 
3
13
  const STORAGE_KEY = 'yaver_feedback_agent';
4
14
  const DEFAULT_PORT = 18080;
@@ -123,9 +133,116 @@ export class YaverDiscovery {
123
133
 
124
134
  if (!target?.quicHost) return null;
125
135
 
136
+ // Try direct connection first (same LAN)
126
137
  const port = target.httpPort ?? DEFAULT_PORT;
127
- const url = `http://${target.quicHost}:${port}`;
128
- return await YaverDiscovery.probe(url);
138
+ const directUrl = `http://${target.quicHost}:${port}`;
139
+ const directResult = await YaverDiscovery.probe(directUrl);
140
+ if (directResult) return directResult;
141
+
142
+ // Direct connection failed — try via HTTP relay (off-LAN)
143
+ const relayResult = await YaverDiscovery.discoverViaRelay(
144
+ base, authToken, target.deviceId,
145
+ );
146
+ if (relayResult) return relayResult;
147
+
148
+ return null;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Discover agent via relay HTTP proxy.
156
+ * Fetches relay server list from Convex platformConfig, then probes
157
+ * `{relayHttpUrl}/d/{deviceId}/health` to reach the agent over the internet.
158
+ */
159
+ static async discoverViaRelay(
160
+ convexUrl: string,
161
+ authToken: string,
162
+ deviceId: string,
163
+ ): Promise<DiscoveryResult | null> {
164
+ try {
165
+ // Fetch relay server list from user settings first, then platform config
166
+ const settingsRes = await fetch(`${convexUrl}/auth/validate`, {
167
+ headers: { Authorization: `Bearer ${authToken}` },
168
+ });
169
+ let relayUrl: string | undefined;
170
+ let relayPassword: string | undefined;
171
+
172
+ if (settingsRes.ok) {
173
+ const settingsData = await settingsRes.json();
174
+ relayUrl = settingsData.relayUrl;
175
+ relayPassword = settingsData.relayPassword;
176
+ }
177
+
178
+ // If no user-level relay, fetch platform relay servers
179
+ if (!relayUrl) {
180
+ const configRes = await fetch(`${convexUrl}/platform-config?key=relay_servers`);
181
+ if (configRes.ok) {
182
+ const configData = await configRes.json();
183
+ const servers = typeof configData.value === 'string'
184
+ ? JSON.parse(configData.value)
185
+ : configData.value;
186
+ if (Array.isArray(servers) && servers.length > 0) {
187
+ // Pick the first (highest priority) relay with an httpUrl
188
+ const relay = servers.find((s: { httpUrl?: string }) => s.httpUrl);
189
+ if (relay) {
190
+ relayUrl = relay.httpUrl;
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ if (!relayUrl) return null;
197
+
198
+ // Probe agent through relay: {relayHttpUrl}/d/{deviceId}/health
199
+ const relayBase = `${relayUrl.replace(/\/$/, '')}/d/${deviceId}`;
200
+ const result = await YaverDiscovery.probeWithHeaders(relayBase, {
201
+ 'X-Relay-Password': relayPassword || '',
202
+ });
203
+ return result;
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Probe with extra headers (e.g. relay password).
211
+ */
212
+ static async probeWithHeaders(
213
+ url: string,
214
+ headers: Record<string, string>,
215
+ ): Promise<DiscoveryResult | null> {
216
+ const base = url.replace(/\/$/, '');
217
+ const start = Date.now();
218
+
219
+ try {
220
+ const controller = new AbortController();
221
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS + 3000); // relay adds latency
222
+
223
+ const response = await fetch(`${base}/health`, {
224
+ method: 'GET',
225
+ headers,
226
+ signal: controller.signal,
227
+ });
228
+
229
+ clearTimeout(timeoutId);
230
+
231
+ if (!response.ok) return null;
232
+
233
+ const latency = Date.now() - start;
234
+ let hostname = 'Unknown';
235
+ let version = 'unknown';
236
+
237
+ try {
238
+ const data = await response.json();
239
+ hostname = data.hostname ?? data.name ?? 'Unknown';
240
+ version = data.version ?? 'unknown';
241
+ } catch {
242
+ // Health endpoint might return plain text
243
+ }
244
+
245
+ return { url: base, hostname, version, latency };
129
246
  } catch {
130
247
  return null;
131
248
  }
@@ -185,8 +302,9 @@ export class YaverDiscovery {
185
302
  return result;
186
303
  }
187
304
 
188
- /** Get the cached agent connection from AsyncStorage. */
305
+ /** Get the cached agent connection from storage. */
189
306
  static async getStored(): Promise<{ url: string; hostname: string } | null> {
307
+ if (!AsyncStorage) return null;
190
308
  try {
191
309
  const raw = await AsyncStorage.getItem(STORAGE_KEY);
192
310
  if (!raw) return null;
@@ -200,8 +318,9 @@ export class YaverDiscovery {
200
318
  }
201
319
  }
202
320
 
203
- /** Store a successful discovery result in AsyncStorage. */
321
+ /** Store a successful discovery result. */
204
322
  static async store(result: DiscoveryResult): Promise<void> {
323
+ if (!AsyncStorage) return;
205
324
  try {
206
325
  await AsyncStorage.setItem(
207
326
  STORAGE_KEY,
@@ -214,6 +333,7 @@ export class YaverDiscovery {
214
333
 
215
334
  /** Clear the stored agent connection. */
216
335
  static async clear(): Promise<void> {
336
+ if (!AsyncStorage) return;
217
337
  try {
218
338
  await AsyncStorage.removeItem(STORAGE_KEY);
219
339
  } catch {
@@ -224,13 +224,13 @@ export const FeedbackModal: React.FC = () => {
224
224
 
225
225
  setIsReloading(true);
226
226
  try {
227
- const response = await fetch(`${config.agentUrl.replace(/\/$/, '')}/exec`, {
227
+ const response = await fetch(`${config.agentUrl.replace(/\/$/, '')}/dev/reload-app`, {
228
228
  method: 'POST',
229
229
  headers: {
230
230
  Authorization: `Bearer ${config.authToken}`,
231
231
  'Content-Type': 'application/json',
232
232
  },
233
- body: JSON.stringify({ command: 'reload', type: 'hot-reload' }),
233
+ body: JSON.stringify({ mode: 'dev' }),
234
234
  });
235
235
  if (response.ok) {
236
236
  BlackBox.lifecycle('Hot reload triggered from feedback SDK');
@@ -119,9 +119,13 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
119
119
  const testPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
120
120
  const outputScrollRef = useRef<ScrollView>(null);
121
121
 
122
- // Resolve agent URL and token
122
+ // Resolve agent URL and token — re-read from config each render
123
+ // because discoverAgent() may set agentUrl asynchronously after init.
124
+ const [resolvedAgentUrl, setResolvedAgentUrl] = useState<string | undefined>(
125
+ agentUrlProp || YaverFeedback.getConfig()?.agentUrl,
126
+ );
123
127
  const config = YaverFeedback.getConfig();
124
- const agentUrl = agentUrlProp || config?.agentUrl;
128
+ const agentUrl = resolvedAgentUrl;
125
129
  const authToken = authTokenProp || config?.authToken;
126
130
  const panelBg = panelBackgroundColor || config?.panelBackgroundColor || DEFAULT_PANEL_BG;
127
131
 
@@ -129,19 +133,27 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
129
133
  setOutput((prev) => [...prev.slice(-20), line]);
130
134
  }, []);
131
135
 
132
- // Connection health polling
136
+ // Connection health polling — also picks up agentUrl from config when
137
+ // it becomes available after background discovery completes.
133
138
  useEffect(() => {
134
- if (!healthCheckInterval || !agentUrl) return;
139
+ if (!healthCheckInterval) return;
135
140
 
136
141
  const check = async () => {
142
+ // Re-read config in case discoverAgent() resolved since last check
143
+ const latestUrl = agentUrlProp || YaverFeedback.getConfig()?.agentUrl;
144
+ if (latestUrl && latestUrl !== resolvedAgentUrl) {
145
+ setResolvedAgentUrl(latestUrl);
146
+ }
147
+ if (!latestUrl) return;
148
+
137
149
  try {
138
150
  const client = YaverFeedback.getP2PClient();
139
151
  if (client) {
140
152
  setIsConnected(await client.health());
141
- } else if (agentUrl) {
153
+ } else {
142
154
  const controller = new AbortController();
143
155
  const timeout = setTimeout(() => controller.abort(), 3000);
144
- const resp = await fetch(`${agentUrl.replace(/\/$/, '')}/health`, {
156
+ const resp = await fetch(`${latestUrl.replace(/\/$/, '')}/health`, {
145
157
  signal: controller.signal,
146
158
  });
147
159
  clearTimeout(timeout);
@@ -155,7 +167,7 @@ export const FloatingButton: React.FC<FloatingButtonProps> = ({
155
167
  check();
156
168
  const interval = setInterval(check, healthCheckInterval);
157
169
  return () => clearInterval(interval);
158
- }, [agentUrl, healthCheckInterval]);
170
+ }, [agentUrlProp, healthCheckInterval, resolvedAgentUrl]);
159
171
 
160
172
  const panResponder = useRef(
161
173
  PanResponder.create({
package/src/P2PClient.ts CHANGED
@@ -214,6 +214,32 @@ export class P2PClient {
214
214
  };
215
215
  }
216
216
 
217
+ /**
218
+ * Trigger a reload of the third-party app.
219
+ * In dev mode, this tells the dev server to hot-reload.
220
+ * In bundle mode, this rebuilds the native bundle and pushes it.
221
+ * The reload command is also broadcast to all connected SDK devices
222
+ * via the BlackBox command channel.
223
+ * @param mode - "dev" for hot reload, "bundle" for native bundle rebuild
224
+ */
225
+ async reloadApp(mode: 'dev' | 'bundle' = 'dev'): Promise<{ ok: boolean }> {
226
+ const response = await fetch(`${this.baseUrl}/dev/reload-app`, {
227
+ method: 'POST',
228
+ headers: {
229
+ Authorization: `Bearer ${this.authToken}`,
230
+ 'Content-Type': 'application/json',
231
+ },
232
+ body: JSON.stringify({ mode }),
233
+ });
234
+
235
+ if (!response.ok) {
236
+ const text = await response.text().catch(() => '');
237
+ throw new Error(`[P2PClient] Reload app failed (${response.status}): ${text}`);
238
+ }
239
+
240
+ return response.json();
241
+ }
242
+
217
243
  /** Get the download URL for a build artifact. */
218
244
  getArtifactUrl(buildId: string): string {
219
245
  return `${this.baseUrl}/builds/${buildId}/artifact`;
@@ -49,6 +49,10 @@ export class YaverFeedback {
49
49
  p2pClient = new P2PClient(config.agentUrl, config.authToken);
50
50
  } else {
51
51
  p2pClient = null;
52
+ // Auto-discover agent in the background when convexUrl or LAN is available
53
+ if (enabled) {
54
+ YaverFeedback.discoverAgent();
55
+ }
52
56
  }
53
57
 
54
58
  // Set up error capture buffer size
@@ -71,6 +75,29 @@ export class YaverFeedback {
71
75
  });
72
76
  }
73
77
 
78
+ // Wire up BlackBox command handlers for reload signals from the agent.
79
+ // This enables the vibe coder to trigger reload from the Yaver mobile app
80
+ // and have the third-party app (with this SDK) automatically reload.
81
+ if (enabled) {
82
+ BlackBox.onCommand((cmd) => {
83
+ if (cmd.command === 'reload') {
84
+ if (cfg.onReload) {
85
+ cfg.onReload();
86
+ } else {
87
+ YaverFeedback.defaultReload();
88
+ }
89
+ } else if (cmd.command === 'reload_bundle' && cmd.data) {
90
+ const bundleUrl = cmd.data.bundleUrl as string;
91
+ const assetsUrl = cmd.data.assetsUrl as string | undefined;
92
+ if (cfg.onReloadBundle) {
93
+ cfg.onReloadBundle(bundleUrl, assetsUrl);
94
+ } else {
95
+ YaverFeedback.defaultReloadBundle(bundleUrl, assetsUrl);
96
+ }
97
+ }
98
+ });
99
+ }
100
+
74
101
  // NOTE: We intentionally do NOT hook ErrorUtils.setGlobalHandler().
75
102
  // Sentry, Crashlytics, Bugsnag, and other tools all compete for that
76
103
  // single slot. Hijacking it would break whichever tool the developer
@@ -82,6 +109,30 @@ export class YaverFeedback {
82
109
  // pass-through wrapper they insert into their own error chain
83
110
  }
84
111
 
112
+ /**
113
+ * Run agent discovery in the background.
114
+ * Called automatically from init() when no agentUrl is provided.
115
+ * Sets config.agentUrl and creates P2PClient on success.
116
+ */
117
+ static async discoverAgent(): Promise<void> {
118
+ if (!config || !enabled) return;
119
+ if (config.agentUrl) return; // already have a URL
120
+
121
+ try {
122
+ const result = await YaverDiscovery.discover({
123
+ convexUrl: config.convexUrl,
124
+ authToken: config.authToken,
125
+ preferredDeviceId: config.preferredDeviceId,
126
+ });
127
+ if (result && config) {
128
+ config.agentUrl = result.url;
129
+ p2pClient = new P2PClient(result.url, config.authToken);
130
+ }
131
+ } catch {
132
+ // Discovery failed — FloatingButton will show disconnected, user can retry
133
+ }
134
+ }
135
+
85
136
  /**
86
137
  * Manually trigger the feedback collection flow.
87
138
  * Opens the feedback modal if the SDK is initialized and enabled.
@@ -331,6 +382,85 @@ export class YaverFeedback {
331
382
  }
332
383
  }
333
384
 
385
+ /**
386
+ * Default reload handler. Tries three strategies in order:
387
+ *
388
+ * 1. **YaverBundleLoader** — running inside Yaver's native container.
389
+ * Pulls fresh Hermes bundle from agent and reloads the RN bridge.
390
+ *
391
+ * 2. **YaverHotReload** — standalone app with feedback SDK's native module
392
+ * (added via Expo config plugin). Downloads Hermes bundle from agent,
393
+ * saves to Documents, and reloads the RN bridge.
394
+ *
395
+ * 3. **DevSettings.reload()** — standalone dev build connected to Metro.
396
+ */
397
+ private static defaultReload(): void {
398
+ if (!config?.agentUrl) return;
399
+ const bundleUrl = `${config.agentUrl}/dev/native-bundle`;
400
+ const headers = { Authorization: `Bearer ${config.authToken}` };
401
+ YaverFeedback.loadBundleAndReload(bundleUrl, headers);
402
+ }
403
+
404
+ /**
405
+ * Default reload_bundle handler. Receives a compiled Hermes bundle URL
406
+ * from the agent and loads it via the best available native mechanism.
407
+ */
408
+ private static defaultReloadBundle(bundleUrl: string, _assetsUrl?: string): void {
409
+ if (!config?.agentUrl) return;
410
+
411
+ const fullUrl = bundleUrl.startsWith('http')
412
+ ? bundleUrl
413
+ : `${config.agentUrl}${bundleUrl}`;
414
+ const headers = { Authorization: `Bearer ${config.authToken}` };
415
+ YaverFeedback.loadBundleAndReload(fullUrl, headers);
416
+ }
417
+
418
+ /**
419
+ * Core bundle reload logic. Tries native loaders in order:
420
+ *
421
+ * 1. YaverBundleLoader (Yaver container — full validation + bridge reload)
422
+ * 2. YaverHotReload (SDK's own native module — download + bridge reload)
423
+ * 3. DevSettings.reload() (Metro dev server fallback)
424
+ */
425
+ private static loadBundleAndReload(
426
+ bundleUrl: string,
427
+ headers: Record<string, string>,
428
+ ): void {
429
+ const { NativeModules } = require('react-native');
430
+
431
+ // Strategy 1: YaverBundleLoader (running inside Yaver container)
432
+ if (NativeModules.YaverBundleLoader) {
433
+ NativeModules.YaverBundleLoader.loadBundle(bundleUrl, 'main', headers)
434
+ .catch((err: Error) => {
435
+ console.warn('[YaverFeedback] YaverBundleLoader reload failed:', err);
436
+ });
437
+ return;
438
+ }
439
+
440
+ // Strategy 2: YaverHotReload (SDK's native module, added by Expo config plugin)
441
+ if (NativeModules.YaverHotReload) {
442
+ NativeModules.YaverHotReload.loadBundle(bundleUrl, headers)
443
+ .catch((err: Error) => {
444
+ console.warn('[YaverFeedback] YaverHotReload reload failed:', err);
445
+ });
446
+ return;
447
+ }
448
+
449
+ // Strategy 3: DevSettings.reload() for Metro dev builds
450
+ console.warn(
451
+ '[YaverFeedback] No native bundle loader available. ' +
452
+ 'Add "yaver-feedback-react-native" to your app.json plugins to enable hot reload.',
453
+ );
454
+ try {
455
+ const { DevSettings } = require('react-native');
456
+ if (typeof DevSettings?.reload === 'function') {
457
+ DevSettings.reload();
458
+ }
459
+ } catch {
460
+ // Not in dev mode
461
+ }
462
+ }
463
+
334
464
  /** Tear down the SDK (stop shake detector, clear state). */
335
465
  static destroy(): void {
336
466
  if (shakeDetector) {
package/src/index.ts CHANGED
@@ -49,6 +49,6 @@ export type {
49
49
  TestFix,
50
50
  TestSession,
51
51
  } from './types';
52
- export type { BlackBoxEvent, BlackBoxConfig } from './BlackBox';
52
+ export type { BlackBoxEvent, BlackBoxConfig, BlackBoxCommand, CommandHandler } from './BlackBox';
53
53
  export type { DiscoveryResult } from './Discovery';
54
54
  export type { FeedbackEvent } from './P2PClient';
package/src/types.ts CHANGED
@@ -102,6 +102,30 @@ export interface FeedbackConfig {
102
102
  * panelBackgroundColor: '#333333' // neutral dark gray
103
103
  */
104
104
  panelBackgroundColor?: string;
105
+ /**
106
+ * Callback invoked when the agent pushes a reload command.
107
+ * This happens when the vibe coder triggers a reload from the Yaver mobile app
108
+ * or from another connected device.
109
+ *
110
+ * If not provided, the SDK will attempt `DevSettings.reload()` in dev mode
111
+ * or ignore the command in production.
112
+ *
113
+ * @example
114
+ * onReload: () => {
115
+ * // Custom reload logic, e.g. re-fetch bundle from agent
116
+ * Updates.reloadAsync();
117
+ * }
118
+ */
119
+ onReload?: () => void;
120
+ /**
121
+ * Callback invoked when the agent pushes a reload_bundle command with a new
122
+ * native bundle URL. The SDK passes the bundle URL and assets URL so the app
123
+ * can fetch and load the new bundle.
124
+ *
125
+ * If not provided, the SDK will attempt to POST the bundle to localhost:8347
126
+ * (Yaver's on-device HTTP server) for native container reload.
127
+ */
128
+ onReloadBundle?: (bundleUrl: string, assetsUrl?: string) => void;
105
129
  /**
106
130
  * TLS certificate fingerprint (SHA256) for HTTPS on LAN.
107
131
  * When set, the SDK prefers HTTPS connections and verifies the agent's