yaver-feedback-react-native 0.6.1 → 0.7.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.
package/src/Discovery.ts CHANGED
@@ -20,9 +20,17 @@ function getAsyncStorage(): AsyncStorageLike | null {
20
20
  }
21
21
  }
22
22
 
23
+ import type { RemoteDevice } from './auth';
24
+ import {
25
+ collapseRemoteDevices,
26
+ pickTargetDevice,
27
+ HEARTBEAT_STALE_MS,
28
+ } from './deviceDedup';
29
+
23
30
  const STORAGE_KEY = 'yaver_feedback_agent';
24
31
  const DEFAULT_PORT = 18080;
25
- const TIMEOUT_MS = 2000;
32
+ const PROBE_TIMEOUT_MS = 2500;
33
+ const RELAY_PROBE_TIMEOUT_MS = 6000;
26
34
 
27
35
  export interface DiscoveryResult {
28
36
  url: string;
@@ -31,29 +39,43 @@ export interface DiscoveryResult {
31
39
  latency: number;
32
40
  }
33
41
 
34
- // Common LAN subnets and host suffixes to scan
35
- const SUBNETS = ['192.168.1', '192.168.0', '10.0.0', '10.0.1'];
36
- const HOST_SUFFIXES = [1, 2, 50, 100, 101, 200];
42
+ // LAN fallback sweep used ONLY when Convex lookup fails AND the stored
43
+ // cache probe fails. Keep tight — covers 192.168.1/0.x and 10.0.0/1.x
44
+ // with a handful of common host suffixes. The primary path is always
45
+ // Convex.
46
+ const LAN_SUBNETS = ['192.168.1', '192.168.0', '10.0.0', '10.0.1'];
47
+ const LAN_HOST_SUFFIXES = [1, 2, 50, 100, 101, 200];
37
48
 
38
49
  /**
39
- * Device discovery for finding Yaver agents on the local network or via Convex.
50
+ * Device discovery for finding Yaver agents.
51
+ *
52
+ * **Convex is the primary source of truth.** The user's Convex account
53
+ * has the freshest IP / port for each registered agent, updated every
54
+ * 2 minutes via heartbeat. The SDK should therefore:
55
+ * 1. On every `discover()` call, re-query Convex for the latest IP
56
+ * (no local cache shortcut when `convexUrl` + `authToken` are
57
+ * available).
58
+ * 2. Dedup the returned list (Convex can carry stale rows after
59
+ * re-pair) and pick the freshest online machine.
60
+ * 3. Probe the machine's `quicHost:httpPort` directly.
61
+ * 4. If the direct probe fails (different LAN / roaming), route
62
+ * through the configured relay.
63
+ * 5. Store the successful URL only AFTER the probe confirms it's
64
+ * reachable. Stored cache is used only as a last-chance shortcut
65
+ * when Convex itself is unreachable.
40
66
  *
41
- * Three discovery strategies (tried in order):
42
- * 1. **Convex cloud** fetch agent IP from Convex `/devices/list` (for cloud machines)
43
- * 2. **Stored connection** try cached URL from last successful connection
44
- * 3. **LAN scan** — probe common LAN IPs via `/health` endpoint
67
+ * Compared to the previous implementation this removes the "trust
68
+ * stored URL first" shortcut that caused the SDK to keep trying a dead
69
+ * cached IP long after the Mac's IP rotated.
45
70
  */
46
71
  export class YaverDiscovery {
47
- /**
48
- * Discover an agent. Tries Convex cloud first (if configured),
49
- * then stored connection, then LAN scan.
50
- */
51
72
  static async discover(options?: {
52
73
  convexUrl?: string;
53
74
  authToken?: string;
54
75
  preferredDeviceId?: string;
55
76
  }): Promise<DiscoveryResult | null> {
56
- // Strategy 1: Convex cloud discovery (for cloud machines)
77
+ // Strategy 1: Convex always tried first when credentials are
78
+ // available. No cache shortcut.
57
79
  if (options?.convexUrl && options?.authToken) {
58
80
  const result = await YaverDiscovery.discoverFromConvex(
59
81
  options.convexUrl,
@@ -66,41 +88,60 @@ export class YaverDiscovery {
66
88
  }
67
89
  }
68
90
 
69
- // Strategy 2: Try stored connection
91
+ // Strategy 2: Stored URL. Only used as a fallback when Convex was
92
+ // unreachable. A successful probe here means either the mobile is
93
+ // offline or Convex is — we'll trust the stored IP.
70
94
  const stored = await YaverDiscovery.getStored();
71
95
  if (stored) {
72
96
  const result = await YaverDiscovery.probe(stored.url);
73
- if (result) {
74
- return result;
75
- }
97
+ if (result) return result;
76
98
  await YaverDiscovery.clear();
77
99
  }
78
100
 
79
- // Strategy 3: Scan common LAN IPs in parallel
101
+ // Strategy 3: LAN fallback. Small sweep of common subnets. This is
102
+ // only hit when the user has no Convex session (device-local mode)
103
+ // or both Convex + cache lookups failed.
80
104
  const candidates: string[] = [];
81
- for (const subnet of SUBNETS) {
82
- for (const suffix of HOST_SUFFIXES) {
105
+ for (const subnet of LAN_SUBNETS) {
106
+ for (const suffix of LAN_HOST_SUFFIXES) {
83
107
  candidates.push(`http://${subnet}.${suffix}:${DEFAULT_PORT}`);
84
108
  }
85
109
  }
86
-
87
110
  const results = await Promise.allSettled(
88
111
  candidates.map((url) => YaverDiscovery.probe(url)),
89
112
  );
90
-
91
113
  for (const r of results) {
92
114
  if (r.status === 'fulfilled' && r.value) {
93
115
  await YaverDiscovery.store(r.value);
94
116
  return r.value;
95
117
  }
96
118
  }
97
-
98
119
  return null;
99
120
  }
100
121
 
101
122
  /**
102
- * Fetch the agent URL from Convex device list or cloud machines.
103
- * No hardcoded IPs needed Convex knows where the agent is.
123
+ * Re-query Convex ignoring any cached URL. Intended for the call
124
+ * site right after a probe/network failure it's the "the IP
125
+ * probably changed, ask the source of truth again" path.
126
+ */
127
+ static async refreshFromConvex(options: {
128
+ convexUrl: string;
129
+ authToken: string;
130
+ preferredDeviceId?: string;
131
+ }): Promise<DiscoveryResult | null> {
132
+ await YaverDiscovery.clear();
133
+ const result = await YaverDiscovery.discoverFromConvex(
134
+ options.convexUrl,
135
+ options.authToken,
136
+ options.preferredDeviceId,
137
+ );
138
+ if (result) await YaverDiscovery.store(result);
139
+ return result;
140
+ }
141
+
142
+ /**
143
+ * Fetch the agent URL from Convex. Dedups rows, prefers fresh ones,
144
+ * falls back to relay if the direct LAN IP isn't reachable.
104
145
  */
105
146
  static async discoverFromConvex(
106
147
  convexUrl: string,
@@ -108,17 +149,17 @@ export class YaverDiscovery {
108
149
  preferredDeviceId?: string,
109
150
  ): Promise<DiscoveryResult | null> {
110
151
  const base = convexUrl.replace(/\/$/, '');
111
-
112
152
  try {
113
- // Try cloud machines first (CPU/GPU managed machines)
153
+ // Try cloud machines first (CPU/GPU managed machines). These are
154
+ // long-lived with stable IPs so the direct probe is cheap.
114
155
  const machinesRes = await fetch(`${base}/machines`, {
115
156
  headers: { Authorization: `Bearer ${authToken}` },
116
157
  });
117
-
118
158
  if (machinesRes.ok) {
119
159
  const { machines } = await machinesRes.json();
120
160
  const activeMachine = (machines ?? []).find(
121
- (m: { status: string; serverIp?: string }) => m.status === 'active' && m.serverIp,
161
+ (m: { status: string; serverIp?: string }) =>
162
+ m.status === 'active' && m.serverIp,
122
163
  );
123
164
  if (activeMachine?.serverIp) {
124
165
  const url = `http://${activeMachine.serverIp}:${DEFAULT_PORT}`;
@@ -127,31 +168,78 @@ export class YaverDiscovery {
127
168
  }
128
169
  }
129
170
 
130
- // Fall back to device list (personal machines registered with Convex)
171
+ // Fall back to personal devices registered in Convex.
131
172
  const devicesRes = await fetch(`${base}/devices/list`, {
132
173
  headers: { Authorization: `Bearer ${authToken}` },
133
174
  });
134
-
135
175
  if (!devicesRes.ok) return null;
136
176
  const data = await devicesRes.json();
137
- const devices = data.devices ?? data ?? [];
138
-
139
- // Find preferred device or first online one
140
- const target = preferredDeviceId
141
- ? devices.find((d: { deviceId: string }) => d.deviceId === preferredDeviceId)
142
- : devices.find((d: { isOnline: boolean }) => d.isOnline);
177
+ const rawList = Array.isArray(data?.devices) ? data.devices : data;
178
+ if (!Array.isArray(rawList) || rawList.length === 0) return null;
179
+
180
+ // Normalise Convex fields → RemoteDevice shape so dedup works the
181
+ // same way `listReachableDevices` does it.
182
+ const normalised: RemoteDevice[] = rawList.map((d: any) => ({
183
+ deviceId: d.deviceId ?? d.id,
184
+ name: d.name ?? '',
185
+ platform: d.platform ?? d.os ?? '',
186
+ isOnline: !!d.isOnline,
187
+ needsAuth: !!d.needsAuth,
188
+ runnerDown: !!d.runnerDown,
189
+ lastHeartbeat: d.lastHeartbeat ?? 0,
190
+ isGuest: !!d.isGuest,
191
+ hostName: d.hostName,
192
+ hostEmail: d.hostEmail,
193
+ accessScope: d.accessScope ?? 'owner',
194
+ quicHost: d.quicHost ?? d.host ?? '',
195
+ quicPort: d.quicPort ?? 0,
196
+ httpPort: d.httpPort ?? d.quicPort,
197
+ publicKey: d.publicKey,
198
+ hwid: d.hardwareId ?? d.hwid,
199
+ localIps: Array.isArray(d.localIps)
200
+ ? d.localIps
201
+ : Array.isArray(d.lanIps)
202
+ ? d.lanIps
203
+ : undefined,
204
+ }));
205
+
206
+ const deduped = collapseRemoteDevices(normalised);
207
+ const target = pickTargetDevice(deduped, preferredDeviceId);
208
+ if (!target) return null;
209
+
210
+ // Build the same candidate set the Yaver mobile app races on: the
211
+ // primary `quicHost` plus every LAN IP reported in the latest
212
+ // heartbeat (`localIps`). Multi-homed hosts commonly advertise
213
+ // en0 + utun (tailscale) + docker0 etc.; probing all of them in
214
+ // parallel makes the SDK "just work" on the same Wi-Fi without
215
+ // depending on which NIC the user's router DHCP'd them from.
216
+ const port = target.httpPort ?? target.quicPort ?? DEFAULT_PORT;
217
+ const ipSet = new Set<string>();
218
+ if (target.quicHost) ipSet.add(target.quicHost);
219
+ for (const ip of target.localIps ?? []) {
220
+ if (ip) ipSet.add(ip);
221
+ }
222
+ const candidates = Array.from(ipSet).map(
223
+ (ip) => `http://${ip}:${port}`,
224
+ );
143
225
 
144
- if (!target?.quicHost) return null;
226
+ if (candidates.length > 0) {
227
+ const direct = await YaverDiscovery.raceProbe(candidates);
228
+ if (direct) return direct;
229
+ }
145
230
 
146
- // Try direct connection first (same LAN)
147
- const port = target.httpPort ?? DEFAULT_PORT;
148
- const directUrl = `http://${target.quicHost}:${port}`;
149
- const directResult = await YaverDiscovery.probe(directUrl);
150
- if (directResult) return directResult;
231
+ // Warn if the chosen target looks stale — informative only; we
232
+ // still fell through to the relay path below.
233
+ const stale =
234
+ target.lastHeartbeat &&
235
+ Date.now() - target.lastHeartbeat > HEARTBEAT_STALE_MS;
236
+ void stale;
151
237
 
152
- // Direct connection failed — try via HTTP relay (off-LAN)
238
+ // Direct probes all failed — route through relay.
153
239
  const relayResult = await YaverDiscovery.discoverViaRelay(
154
- base, authToken, target.deviceId,
240
+ base,
241
+ authToken,
242
+ target.deviceId,
155
243
  );
156
244
  if (relayResult) return relayResult;
157
245
 
@@ -162,9 +250,47 @@ export class YaverDiscovery {
162
250
  }
163
251
 
164
252
  /**
165
- * Discover agent via relay HTTP proxy.
166
- * Fetches relay server list from Convex platformConfig, then probes
167
- * `{relayHttpUrl}/d/{deviceId}/health` to reach the agent over the internet.
253
+ * Race `/health` probes across N URLs in parallel. First 200 wins;
254
+ * everything else is abandoned. Mirrors the mobile app's
255
+ * `raceDirectCandidates` pattern the single most reliable thing it
256
+ * does on same-LAN.
257
+ */
258
+ static async raceProbe(urls: string[]): Promise<DiscoveryResult | null> {
259
+ if (!urls || urls.length === 0) return null;
260
+ const attempts = urls.map((url) =>
261
+ YaverDiscovery.probe(url).then((r) => {
262
+ if (!r) throw new Error('no-200');
263
+ return r;
264
+ }),
265
+ );
266
+ try {
267
+ // `Promise.any` isn't on every RN runtime yet (older Hermes).
268
+ // Hand-roll the same behaviour so we don't need a polyfill.
269
+ return await new Promise<DiscoveryResult | null>((resolve) => {
270
+ let remaining = attempts.length;
271
+ let settled = false;
272
+ attempts.forEach((p) => {
273
+ p.then((r) => {
274
+ if (settled) return;
275
+ settled = true;
276
+ resolve(r);
277
+ }).catch(() => {
278
+ remaining -= 1;
279
+ if (remaining <= 0 && !settled) {
280
+ settled = true;
281
+ resolve(null);
282
+ }
283
+ });
284
+ });
285
+ });
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Discover agent via relay HTTP proxy. Uses the user's configured
293
+ * relay first (from `/auth/validate`), then the platform relay list.
168
294
  */
169
295
  static async discoverViaRelay(
170
296
  convexUrl: string,
@@ -172,7 +298,6 @@ export class YaverDiscovery {
172
298
  deviceId: string,
173
299
  ): Promise<DiscoveryResult | null> {
174
300
  try {
175
- // Fetch relay server list from user settings first, then platform config
176
301
  const settingsRes = await fetch(`${convexUrl}/auth/validate`, {
177
302
  headers: { Authorization: `Bearer ${authToken}` },
178
303
  });
@@ -185,134 +310,98 @@ export class YaverDiscovery {
185
310
  relayPassword = settingsData.relayPassword;
186
311
  }
187
312
 
188
- // If no user-level relay, fetch platform relay servers
189
313
  if (!relayUrl) {
190
314
  const configRes = await fetch(`${convexUrl}/platform-config?key=relay_servers`);
191
315
  if (configRes.ok) {
192
316
  const configData = await configRes.json();
193
- const servers = typeof configData.value === 'string'
194
- ? JSON.parse(configData.value)
195
- : configData.value;
317
+ const servers =
318
+ typeof configData.value === 'string'
319
+ ? JSON.parse(configData.value)
320
+ : configData.value;
196
321
  if (Array.isArray(servers) && servers.length > 0) {
197
- // Pick the first (highest priority) relay with an httpUrl
198
322
  const relay = servers.find((s: { httpUrl?: string }) => s.httpUrl);
199
- if (relay) {
200
- relayUrl = relay.httpUrl;
201
- }
323
+ if (relay) relayUrl = relay.httpUrl;
202
324
  }
203
325
  }
204
326
  }
205
-
206
327
  if (!relayUrl) return null;
207
328
 
208
- // Probe agent through relay: {relayHttpUrl}/d/{deviceId}/health
209
329
  const relayBase = `${relayUrl.replace(/\/$/, '')}/d/${deviceId}`;
210
- const result = await YaverDiscovery.probeWithHeaders(relayBase, {
330
+ return YaverDiscovery.probeWithHeaders(relayBase, {
211
331
  'X-Relay-Password': relayPassword || '',
212
332
  });
213
- return result;
214
333
  } catch {
215
334
  return null;
216
335
  }
217
336
  }
218
337
 
219
- /**
220
- * Probe with extra headers (e.g. relay password).
221
- */
222
338
  static async probeWithHeaders(
223
339
  url: string,
224
340
  headers: Record<string, string>,
225
341
  ): Promise<DiscoveryResult | null> {
226
342
  const base = url.replace(/\/$/, '');
227
343
  const start = Date.now();
228
-
229
344
  try {
230
345
  const controller = new AbortController();
231
- const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS + 3000); // relay adds latency
232
-
346
+ const timeoutId = setTimeout(() => controller.abort(), RELAY_PROBE_TIMEOUT_MS);
233
347
  const response = await fetch(`${base}/health`, {
234
348
  method: 'GET',
235
349
  headers,
236
350
  signal: controller.signal,
237
351
  });
238
-
239
352
  clearTimeout(timeoutId);
240
-
241
353
  if (!response.ok) return null;
242
-
243
354
  const latency = Date.now() - start;
244
355
  let hostname = 'Unknown';
245
356
  let version = 'unknown';
246
-
247
357
  try {
248
358
  const data = await response.json();
249
359
  hostname = data.hostname ?? data.name ?? 'Unknown';
250
360
  version = data.version ?? 'unknown';
251
361
  } catch {
252
- // Health endpoint might return plain text
362
+ // /health may return plain text
253
363
  }
254
-
255
364
  return { url: base, hostname, version, latency };
256
365
  } catch {
257
366
  return null;
258
367
  }
259
368
  }
260
369
 
261
- /**
262
- * Probe a specific URL for a running Yaver agent.
263
- * Hits the `/health` endpoint with a 2s timeout.
264
- */
370
+ /** Probe a specific URL for a running Yaver agent (2.5 s timeout). */
265
371
  static async probe(url: string): Promise<DiscoveryResult | null> {
266
372
  const base = url.replace(/\/$/, '');
267
373
  const start = Date.now();
268
-
269
374
  try {
270
375
  const controller = new AbortController();
271
- const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
272
-
376
+ const timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
273
377
  const response = await fetch(`${base}/health`, {
274
378
  method: 'GET',
275
379
  signal: controller.signal,
276
380
  });
277
-
278
381
  clearTimeout(timeoutId);
279
-
280
- if (!response.ok) {
281
- return null;
282
- }
283
-
382
+ if (!response.ok) return null;
284
383
  const latency = Date.now() - start;
285
-
286
384
  let hostname = 'Unknown';
287
385
  let version = 'unknown';
288
-
289
386
  try {
290
387
  const data = await response.json();
291
388
  hostname = data.hostname ?? data.name ?? 'Unknown';
292
389
  version = data.version ?? 'unknown';
293
390
  } catch {
294
- // Health endpoint might return plain text — that's fine
391
+ // /health may return plain text
295
392
  }
296
-
297
393
  return { url: base, hostname, version, latency };
298
394
  } catch {
299
395
  return null;
300
396
  }
301
397
  }
302
398
 
303
- /**
304
- * Manually connect to a specific agent URL.
305
- * Probes the URL and stores the connection if successful.
306
- */
307
399
  static async connect(url: string): Promise<DiscoveryResult | null> {
308
400
  const result = await YaverDiscovery.probe(url);
309
- if (result) {
310
- await YaverDiscovery.store(result);
311
- }
401
+ if (result) await YaverDiscovery.store(result);
312
402
  return result;
313
403
  }
314
404
 
315
- /** Get the cached agent connection from storage. */
316
405
  static async getStored(): Promise<{ url: string; hostname: string } | null> {
317
406
  const storage = getAsyncStorage();
318
407
  if (!storage) return null;
@@ -329,7 +418,6 @@ export class YaverDiscovery {
329
418
  }
330
419
  }
331
420
 
332
- /** Store a successful discovery result. */
333
421
  static async store(result: DiscoveryResult): Promise<void> {
334
422
  const storage = getAsyncStorage();
335
423
  if (!storage) return;
@@ -343,7 +431,6 @@ export class YaverDiscovery {
343
431
  }
344
432
  }
345
433
 
346
- /** Clear the stored agent connection. */
347
434
  static async clear(): Promise<void> {
348
435
  const storage = getAsyncStorage();
349
436
  if (!storage) return;