@blario/react-native 0.0.0 → 0.1.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.
Files changed (313) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -0
  3. package/dist/BlarProvider.d.ts +74 -0
  4. package/dist/actions/batched.d.ts +1 -0
  5. package/dist/actions/click.d.ts +4 -0
  6. package/dist/actions/commit-watch.d.ts +15 -0
  7. package/dist/actions/events.d.ts +35 -0
  8. package/dist/actions/executor.d.ts +29 -0
  9. package/dist/actions/highlight.d.ts +4 -0
  10. package/dist/actions/interaction-guards.d.ts +9 -0
  11. package/dist/actions/landing.d.ts +11 -0
  12. package/dist/actions/navigation-driver.d.ts +22 -0
  13. package/dist/actions/parse.d.ts +4 -0
  14. package/dist/actions/result.d.ts +4 -0
  15. package/dist/actions/scrolling.d.ts +12 -0
  16. package/dist/actions/services.d.ts +20 -0
  17. package/dist/actions/settle.d.ts +13 -0
  18. package/dist/actions/switch.d.ts +3 -0
  19. package/dist/actions/target.d.ts +25 -0
  20. package/dist/actions/text-input.d.ts +9 -0
  21. package/dist/actions/types.d.ts +59 -0
  22. package/dist/agent/batch-commands.d.ts +17 -0
  23. package/dist/agent/compose.d.ts +23 -0
  24. package/dist/agent/controller.d.ts +28 -0
  25. package/dist/agent/frame-runner.d.ts +9 -0
  26. package/dist/agent/frames.d.ts +180 -0
  27. package/dist/agent/handlers/agent-command.d.ts +5 -0
  28. package/dist/agent/handlers/batch.d.ts +5 -0
  29. package/dist/agent/handlers/consent-screenshot.d.ts +4 -0
  30. package/dist/agent/handlers/context.d.ts +16 -0
  31. package/dist/agent/handlers/dom-state.d.ts +4 -0
  32. package/dist/agent/handlers/element-context.d.ts +2 -0
  33. package/dist/agent/handlers/screenshot.d.ts +3 -0
  34. package/dist/agent/handlers/text-dom.d.ts +5 -0
  35. package/dist/agent/stage.d.ts +13 -0
  36. package/dist/agent-mode/action-description.d.ts +3 -0
  37. package/dist/agent-mode/bubbles.d.ts +10 -0
  38. package/dist/agent-mode/colors.d.ts +12 -0
  39. package/dist/agent-mode/controller.d.ts +92 -0
  40. package/dist/agent-mode/cursor-follow.d.ts +12 -0
  41. package/dist/agent-mode/cursor.d.ts +37 -0
  42. package/dist/agent-mode/frames.d.ts +36 -0
  43. package/dist/agent-mode/haptics.d.ts +6 -0
  44. package/dist/agent-mode/keep-awake.d.ts +19 -0
  45. package/dist/agent-mode/layer-stacking.d.ts +7 -0
  46. package/dist/agent-mode/markdown.d.ts +19 -0
  47. package/dist/agent-mode/panel-geometry.d.ts +6 -0
  48. package/dist/agent-mode/resume.d.ts +13 -0
  49. package/dist/agent-mode/run-memory.d.ts +19 -0
  50. package/dist/agent-mode/spotlight-controller.d.ts +68 -0
  51. package/dist/agent-mode/spotlight.d.ts +6 -0
  52. package/dist/agent-mode/state.d.ts +9 -0
  53. package/dist/agent-mode/status.d.ts +11 -0
  54. package/dist/agent-mode/timers.d.ts +6 -0
  55. package/dist/agent-mode/ui/AgentDock.d.ts +37 -0
  56. package/dist/agent-mode/ui/AgentLayer.d.ts +19 -0
  57. package/dist/agent-mode/ui/AgentLayerHost.d.ts +8 -0
  58. package/dist/agent-mode/ui/AgentPanel.d.ts +19 -0
  59. package/dist/agent-mode/ui/AgentTap.d.ts +10 -0
  60. package/dist/agent-mode/ui/MarkdownText.d.ts +8 -0
  61. package/dist/agent-mode/ui/MessageBubbles.d.ts +9 -0
  62. package/dist/agent-mode/ui/PendingOverlay.d.ts +5 -0
  63. package/dist/agent-mode/ui/SpotlightLayer.d.ts +9 -0
  64. package/dist/agent-mode/ui/SpotlightOverlay.d.ts +7 -0
  65. package/dist/agent-mode/ui/StatusCard.d.ts +14 -0
  66. package/dist/agent-mode/ui/TargetPulse.d.ts +9 -0
  67. package/dist/agent-mode/ui/TouchShield.d.ts +5 -0
  68. package/dist/agent-mode/ui/WarningIcon.d.ts +6 -0
  69. package/dist/agent-mode/ui/full-window-overlay.d.ts +5 -0
  70. package/dist/agent-mode/ui/layer-host.d.ts +2 -0
  71. package/dist/backend/api-client.d.ts +38 -0
  72. package/dist/backend/api-types.d.ts +25 -0
  73. package/dist/backend/app-info.d.ts +14 -0
  74. package/dist/backend/backend-transport.d.ts +70 -0
  75. package/dist/backend/backoff.d.ts +9 -0
  76. package/dist/backend/base64.d.ts +1 -0
  77. package/dist/backend/errors.d.ts +17 -0
  78. package/dist/backend/foreground.d.ts +1 -0
  79. package/dist/backend/health.d.ts +17 -0
  80. package/dist/backend/identity-proof.d.ts +23 -0
  81. package/dist/backend/identity.d.ts +27 -0
  82. package/dist/backend/message-metadata.d.ts +19 -0
  83. package/dist/backend/presence.d.ts +20 -0
  84. package/dist/backend/response-cache.d.ts +9 -0
  85. package/dist/backend/session-change.d.ts +2 -0
  86. package/dist/backend/session-socket.d.ts +49 -0
  87. package/dist/backend/session.d.ts +39 -0
  88. package/dist/backend/socket-url.d.ts +9 -0
  89. package/dist/backend/storage.d.ts +13 -0
  90. package/dist/backend/tab-id.d.ts +2 -0
  91. package/dist/blar.d.ts +37 -0
  92. package/dist/chat/ChatLayer.d.ts +29 -0
  93. package/dist/chat/ChatSheet.d.ts +11 -0
  94. package/dist/chat/ChatView.d.ts +19 -0
  95. package/dist/chat/Launcher.d.ts +14 -0
  96. package/dist/chat/bridge.d.ts +22 -0
  97. package/dist/chat/chat-coordinator.d.ts +57 -0
  98. package/dist/chat/chat-state.d.ts +20 -0
  99. package/dist/chat/navigation.d.ts +5 -0
  100. package/dist/chat/options.d.ts +22 -0
  101. package/dist/chat/webview.d.ts +10 -0
  102. package/dist/chat/widget-bundle.generated.d.ts +2 -0
  103. package/dist/chat/widget-shell.d.ts +24 -0
  104. package/dist/confirmation/port.d.ts +12 -0
  105. package/dist/fiber/locate.d.ts +7 -0
  106. package/dist/fiber/narrow.d.ts +5 -0
  107. package/dist/fiber/reader.d.ts +10 -0
  108. package/dist/fiber/text.d.ts +4 -0
  109. package/dist/fiber/types.d.ts +21 -0
  110. package/dist/host/border-radius.d.ts +1 -0
  111. package/dist/host/instance.d.ts +9 -0
  112. package/dist/host/measure.d.ts +3 -0
  113. package/dist/host/rect.d.ts +22 -0
  114. package/dist/host/visible-area.d.ts +2 -0
  115. package/dist/index.d.ts +10 -0
  116. package/dist/nudge/NudgeBubble.d.ts +15 -0
  117. package/dist/nudge/nudge.d.ts +23 -0
  118. package/dist/optional/expo-haptics.d.ts +1 -0
  119. package/dist/optional/expo-keep-awake.d.ts +1 -0
  120. package/dist/optional/react-native-haptic-feedback.d.ts +1 -0
  121. package/dist/optional/react-native-keep-awake.d.ts +1 -0
  122. package/dist/overlay/AgentOverlay.d.ts +5 -0
  123. package/dist/screenshot/capture-backend.d.ts +6 -0
  124. package/dist/screenshot/screenshotter.d.ts +18 -0
  125. package/dist/screenshot/skia.d.ts +35 -0
  126. package/dist/screenshot/view-shot.d.ts +2 -0
  127. package/dist/snapshot/capture.d.ts +36 -0
  128. package/dist/snapshot/classify.d.ts +11 -0
  129. package/dist/snapshot/collect.d.ts +27 -0
  130. package/dist/snapshot/element-context.d.ts +9 -0
  131. package/dist/snapshot/icon.d.ts +2 -0
  132. package/dist/snapshot/kinds.d.ts +3 -0
  133. package/dist/snapshot/modals.d.ts +2 -0
  134. package/dist/snapshot/navigation.d.ts +14 -0
  135. package/dist/snapshot/ordering.d.ts +2 -0
  136. package/dist/snapshot/properties.d.ts +14 -0
  137. package/dist/snapshot/registry.d.ts +16 -0
  138. package/dist/snapshot/render.d.ts +10 -0
  139. package/dist/snapshot/route-summary.d.ts +3 -0
  140. package/dist/snapshot/scroll.d.ts +15 -0
  141. package/dist/snapshot/text-layout.d.ts +8 -0
  142. package/dist/snapshot/view.d.ts +30 -0
  143. package/dist/snapshot/visibility.d.ts +17 -0
  144. package/dist/snapshot/wrappers.d.ts +11 -0
  145. package/dist/telemetry/SdkErrorBoundary.d.ts +15 -0
  146. package/dist/telemetry/build-info.generated.d.ts +2 -0
  147. package/dist/telemetry/envelope.d.ts +53 -0
  148. package/dist/telemetry/reporter.d.ts +33 -0
  149. package/dist/telemetry/scrub.d.ts +1 -0
  150. package/dist/telemetry/sdk-reporter.d.ts +5 -0
  151. package/dist/transport/connection-options.d.ts +18 -0
  152. package/dist/transport/relay-transport.d.ts +17 -0
  153. package/dist/transport/transport.d.ts +7 -0
  154. package/dist/ui/insets.d.ts +8 -0
  155. package/dist/ui/strings.d.ts +33 -0
  156. package/dist/ui/useInsets.d.ts +2 -0
  157. package/dist/version.d.ts +1 -0
  158. package/package.json +84 -3
  159. package/src/BlarProvider.tsx +462 -0
  160. package/src/actions/batched.ts +16 -0
  161. package/src/actions/click.ts +54 -0
  162. package/src/actions/commit-watch.ts +86 -0
  163. package/src/actions/events.ts +58 -0
  164. package/src/actions/executor.ts +147 -0
  165. package/src/actions/highlight.ts +13 -0
  166. package/src/actions/interaction-guards.ts +44 -0
  167. package/src/actions/landing.ts +26 -0
  168. package/src/actions/navigation-driver.ts +131 -0
  169. package/src/actions/parse.ts +82 -0
  170. package/src/actions/result.ts +14 -0
  171. package/src/actions/scrolling.ts +112 -0
  172. package/src/actions/services.ts +24 -0
  173. package/src/actions/settle.ts +40 -0
  174. package/src/actions/switch.ts +31 -0
  175. package/src/actions/target.ts +77 -0
  176. package/src/actions/text-input.ts +82 -0
  177. package/src/actions/types.ts +52 -0
  178. package/src/agent/batch-commands.ts +33 -0
  179. package/src/agent/compose.ts +67 -0
  180. package/src/agent/controller.ts +106 -0
  181. package/src/agent/frame-runner.ts +27 -0
  182. package/src/agent/frames.ts +193 -0
  183. package/src/agent/handlers/agent-command.ts +74 -0
  184. package/src/agent/handlers/batch.ts +72 -0
  185. package/src/agent/handlers/consent-screenshot.ts +44 -0
  186. package/src/agent/handlers/context.ts +17 -0
  187. package/src/agent/handlers/dom-state.ts +19 -0
  188. package/src/agent/handlers/element-context.ts +9 -0
  189. package/src/agent/handlers/screenshot.ts +24 -0
  190. package/src/agent/handlers/text-dom.ts +17 -0
  191. package/src/agent/stage.ts +22 -0
  192. package/src/agent-mode/action-description.ts +25 -0
  193. package/src/agent-mode/bubbles.ts +31 -0
  194. package/src/agent-mode/colors.ts +40 -0
  195. package/src/agent-mode/controller.ts +459 -0
  196. package/src/agent-mode/cursor-follow.ts +43 -0
  197. package/src/agent-mode/cursor.ts +118 -0
  198. package/src/agent-mode/frames.ts +70 -0
  199. package/src/agent-mode/haptics.ts +49 -0
  200. package/src/agent-mode/keep-awake.ts +107 -0
  201. package/src/agent-mode/layer-stacking.ts +16 -0
  202. package/src/agent-mode/markdown.ts +84 -0
  203. package/src/agent-mode/panel-geometry.ts +27 -0
  204. package/src/agent-mode/resume.ts +16 -0
  205. package/src/agent-mode/run-memory.ts +65 -0
  206. package/src/agent-mode/spotlight-controller.ts +255 -0
  207. package/src/agent-mode/spotlight.ts +14 -0
  208. package/src/agent-mode/state.ts +36 -0
  209. package/src/agent-mode/status.ts +13 -0
  210. package/src/agent-mode/timers.ts +11 -0
  211. package/src/agent-mode/ui/AgentDock.tsx +285 -0
  212. package/src/agent-mode/ui/AgentLayer.tsx +123 -0
  213. package/src/agent-mode/ui/AgentLayerHost.tsx +37 -0
  214. package/src/agent-mode/ui/AgentPanel.tsx +187 -0
  215. package/src/agent-mode/ui/AgentTap.tsx +190 -0
  216. package/src/agent-mode/ui/MarkdownText.tsx +55 -0
  217. package/src/agent-mode/ui/MessageBubbles.tsx +58 -0
  218. package/src/agent-mode/ui/PendingOverlay.tsx +32 -0
  219. package/src/agent-mode/ui/SpotlightLayer.tsx +60 -0
  220. package/src/agent-mode/ui/SpotlightOverlay.tsx +35 -0
  221. package/src/agent-mode/ui/StatusCard.tsx +137 -0
  222. package/src/agent-mode/ui/TargetPulse.tsx +74 -0
  223. package/src/agent-mode/ui/TouchShield.tsx +22 -0
  224. package/src/agent-mode/ui/WarningIcon.tsx +32 -0
  225. package/src/agent-mode/ui/full-window-overlay.ts +16 -0
  226. package/src/agent-mode/ui/layer-host.ts +11 -0
  227. package/src/backend/api-client.ts +210 -0
  228. package/src/backend/api-types.ts +56 -0
  229. package/src/backend/app-info.ts +40 -0
  230. package/src/backend/backend-transport.ts +338 -0
  231. package/src/backend/backoff.ts +16 -0
  232. package/src/backend/base64.ts +25 -0
  233. package/src/backend/errors.ts +32 -0
  234. package/src/backend/foreground.ts +10 -0
  235. package/src/backend/health.ts +64 -0
  236. package/src/backend/identity-proof.ts +151 -0
  237. package/src/backend/identity.ts +31 -0
  238. package/src/backend/message-metadata.ts +28 -0
  239. package/src/backend/presence.ts +95 -0
  240. package/src/backend/response-cache.ts +45 -0
  241. package/src/backend/session-change.ts +14 -0
  242. package/src/backend/session-socket.ts +292 -0
  243. package/src/backend/session.ts +188 -0
  244. package/src/backend/socket-url.ts +25 -0
  245. package/src/backend/storage.ts +49 -0
  246. package/src/backend/tab-id.ts +24 -0
  247. package/src/blar.ts +100 -0
  248. package/src/chat/ChatLayer.tsx +109 -0
  249. package/src/chat/ChatSheet.tsx +82 -0
  250. package/src/chat/ChatView.tsx +127 -0
  251. package/src/chat/Launcher.tsx +72 -0
  252. package/src/chat/bridge.ts +56 -0
  253. package/src/chat/chat-coordinator.ts +189 -0
  254. package/src/chat/chat-state.ts +35 -0
  255. package/src/chat/navigation.ts +15 -0
  256. package/src/chat/options.ts +39 -0
  257. package/src/chat/webview.ts +23 -0
  258. package/src/chat/widget-bundle.generated.ts +2 -0
  259. package/src/chat/widget-shell.ts +170 -0
  260. package/src/confirmation/port.ts +15 -0
  261. package/src/fiber/locate.ts +41 -0
  262. package/src/fiber/narrow.ts +15 -0
  263. package/src/fiber/reader.ts +98 -0
  264. package/src/fiber/text.ts +52 -0
  265. package/src/fiber/types.ts +24 -0
  266. package/src/host/border-radius.ts +11 -0
  267. package/src/host/instance.ts +30 -0
  268. package/src/host/measure.ts +25 -0
  269. package/src/host/rect.ts +45 -0
  270. package/src/host/visible-area.ts +5 -0
  271. package/src/index.ts +10 -0
  272. package/src/nudge/NudgeBubble.tsx +117 -0
  273. package/src/nudge/nudge.ts +100 -0
  274. package/src/optional/expo-haptics.ts +11 -0
  275. package/src/optional/expo-keep-awake.ts +11 -0
  276. package/src/optional/react-native-haptic-feedback.ts +11 -0
  277. package/src/optional/react-native-keep-awake.ts +11 -0
  278. package/src/overlay/AgentOverlay.tsx +16 -0
  279. package/src/screenshot/capture-backend.ts +7 -0
  280. package/src/screenshot/screenshotter.ts +39 -0
  281. package/src/screenshot/skia.ts +107 -0
  282. package/src/screenshot/view-shot.ts +41 -0
  283. package/src/snapshot/capture.ts +163 -0
  284. package/src/snapshot/classify.ts +40 -0
  285. package/src/snapshot/collect.ts +136 -0
  286. package/src/snapshot/element-context.ts +66 -0
  287. package/src/snapshot/icon.ts +21 -0
  288. package/src/snapshot/kinds.ts +10 -0
  289. package/src/snapshot/modals.ts +21 -0
  290. package/src/snapshot/navigation.ts +47 -0
  291. package/src/snapshot/ordering.ts +26 -0
  292. package/src/snapshot/properties.ts +71 -0
  293. package/src/snapshot/registry.ts +33 -0
  294. package/src/snapshot/render.ts +140 -0
  295. package/src/snapshot/route-summary.ts +15 -0
  296. package/src/snapshot/scroll.ts +90 -0
  297. package/src/snapshot/text-layout.ts +59 -0
  298. package/src/snapshot/view.ts +87 -0
  299. package/src/snapshot/visibility.ts +63 -0
  300. package/src/snapshot/wrappers.ts +69 -0
  301. package/src/telemetry/SdkErrorBoundary.tsx +28 -0
  302. package/src/telemetry/build-info.generated.ts +2 -0
  303. package/src/telemetry/envelope.ts +83 -0
  304. package/src/telemetry/reporter.ts +170 -0
  305. package/src/telemetry/scrub.ts +10 -0
  306. package/src/telemetry/sdk-reporter.ts +37 -0
  307. package/src/transport/connection-options.ts +65 -0
  308. package/src/transport/relay-transport.ts +82 -0
  309. package/src/transport/transport.ts +8 -0
  310. package/src/ui/insets.ts +31 -0
  311. package/src/ui/strings.ts +102 -0
  312. package/src/ui/useInsets.ts +10 -0
  313. package/src/version.ts +1 -0
@@ -0,0 +1,95 @@
1
+ import { AppState, type NativeEventSubscription } from 'react-native';
2
+
3
+ import type { ApiClient } from './api-client.ts';
4
+
5
+ const HEARTBEAT_INTERVAL_MS = 30_000;
6
+ const IDLE_TIMEOUT_MS = 3 * 60_000;
7
+
8
+ export class PresenceManager {
9
+ private timer: ReturnType<typeof setTimeout> | null = null;
10
+
11
+ private subscription: NativeEventSubscription | null = null;
12
+
13
+ private lastInteraction = 0;
14
+
15
+ private inFlight = false;
16
+
17
+ private started = false;
18
+
19
+ constructor(
20
+ private readonly api: ApiClient,
21
+ private readonly onPendingNudge: () => void = () => undefined,
22
+ private readonly log: (message: string) => void = () => undefined,
23
+ private readonly now: () => number = () => Date.now(),
24
+ ) {}
25
+
26
+ start(): void {
27
+ if (this.started) {
28
+ return;
29
+ }
30
+ this.started = true;
31
+ this.lastInteraction = this.now();
32
+ this.subscription = AppState.addEventListener('change', (state) => {
33
+ if (state === 'active') {
34
+ this.lastInteraction = this.now();
35
+ this.fireNow();
36
+ }
37
+ });
38
+ this.fireNow();
39
+ }
40
+
41
+ stop(): void {
42
+ this.started = false;
43
+ if (this.timer !== null) {
44
+ clearTimeout(this.timer);
45
+ this.timer = null;
46
+ }
47
+ this.subscription?.remove();
48
+ this.subscription = null;
49
+ }
50
+
51
+ noteInteraction(): void {
52
+ const wasIdle = !this.isPresent();
53
+ this.lastInteraction = this.now();
54
+ if (wasIdle && this.started) {
55
+ this.fireNow();
56
+ }
57
+ }
58
+
59
+ isPresent(): boolean {
60
+ return AppState.currentState === 'active' && this.now() - this.lastInteraction <= IDLE_TIMEOUT_MS;
61
+ }
62
+
63
+ private fireNow(): void {
64
+ if (this.isPresent()) {
65
+ void this.send();
66
+ }
67
+ this.scheduleNext();
68
+ }
69
+
70
+ private scheduleNext(): void {
71
+ if (this.timer !== null) {
72
+ clearTimeout(this.timer);
73
+ }
74
+ this.timer = setTimeout(() => this.fireNow(), HEARTBEAT_INTERVAL_MS);
75
+ }
76
+
77
+ private async send(): Promise<void> {
78
+ if (this.inFlight || this.api.getSourceId() === null || !this.started) {
79
+ return;
80
+ }
81
+ this.inFlight = true;
82
+ try {
83
+ const response = await this.api.heartbeat();
84
+ this.log(`heartbeat ok pending_nudge=${response.hasPendingNudge}`);
85
+ if (response.hasPendingNudge) {
86
+ this.onPendingNudge();
87
+ }
88
+ } catch (error) {
89
+ this.log(`heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
90
+ return;
91
+ } finally {
92
+ this.inFlight = false;
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,45 @@
1
+ const CACHE_LIMIT = 50;
2
+ const CACHE_TTL_MS = 60_000;
3
+
4
+ interface CachedFrame {
5
+ payload: string;
6
+ storedAt: number;
7
+ }
8
+
9
+ export class ResponseCache {
10
+ private readonly frames = new Map<string, CachedFrame>();
11
+
12
+ constructor(private readonly now: () => number = () => Date.now()) {}
13
+
14
+ store(commandId: string, payload: string): void {
15
+ this.sweep();
16
+ this.frames.set(commandId, { payload, storedAt: this.now() });
17
+ while (this.frames.size > CACHE_LIMIT) {
18
+ const oldest = this.frames.keys().next().value;
19
+ if (oldest === undefined) {
20
+ break;
21
+ }
22
+ this.frames.delete(oldest);
23
+ }
24
+ }
25
+
26
+ clear(): void {
27
+ this.frames.clear();
28
+ }
29
+
30
+ drain(): string[] {
31
+ this.sweep();
32
+ const payloads = [...this.frames.values()].map((frame) => frame.payload);
33
+ this.frames.clear();
34
+ return payloads;
35
+ }
36
+
37
+ private sweep(): void {
38
+ const expiry = this.now() - CACHE_TTL_MS;
39
+ for (const [commandId, frame] of this.frames) {
40
+ if (frame.storedAt < expiry) {
41
+ this.frames.delete(commandId);
42
+ }
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,14 @@
1
+ import type { SessionState } from './session.ts';
2
+
3
+ export function watchSessionChanges(subscribe: (listener: (state: SessionState) => void) => () => void, onChanged: () => void): () => void {
4
+ let known: number | null = null;
5
+ return subscribe(({ sessionId }) => {
6
+ if (sessionId === null) {
7
+ return;
8
+ }
9
+ if (known !== null && sessionId !== known) {
10
+ onChanged();
11
+ }
12
+ known = sessionId;
13
+ });
14
+ }
@@ -0,0 +1,292 @@
1
+ import { isRecord } from '../fiber/narrow.ts';
2
+ import { backoffDelay, isExhausted, SOCKET_BACKOFF } from './backoff.ts';
3
+ import type { IdentityProof } from './identity.ts';
4
+ import { ResponseCache } from './response-cache.ts';
5
+ import { buildSocketUrl } from './socket-url.ts';
6
+
7
+ export type SocketState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed';
8
+
9
+ export interface SessionSocketOptions {
10
+ apiBaseUrl: string;
11
+ publishableKey: string;
12
+ tabId: string;
13
+ getSourceId: () => string | null;
14
+ getProof: () => Promise<IdentityProof | null> | null;
15
+ refreshProof: () => Promise<IdentityProof | null>;
16
+ getLastSeenSeq: () => number;
17
+ onMessage: (raw: string, frame: Record<string, unknown> | null) => void;
18
+ onState: (state: SocketState) => void;
19
+ onIdentityRejected: () => void;
20
+ watchForeground: (onActive: () => void) => () => void;
21
+ }
22
+
23
+ const IDENTITY_REJECTED_CLOSE_CODE = 4001;
24
+ const STALE_CLOSE_CODE = 1006;
25
+ const STALE_AFTER_MS = 45_000;
26
+ const LIVENESS_CHECK_MS = 5_000;
27
+ const EXHAUSTED_RETRY_MS = 60_000;
28
+
29
+ function parseFrame(raw: string): Record<string, unknown> | null {
30
+ try {
31
+ const parsed: unknown = JSON.parse(raw);
32
+ return isRecord(parsed) ? parsed : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export class SessionSocket {
39
+ private socket: WebSocket | null = null;
40
+
41
+ private sessionId: number | null = null;
42
+
43
+ private epoch = 0;
44
+
45
+ private attempts = 0;
46
+
47
+ private intentionallyClosed = true;
48
+
49
+ private identityRefreshUsed = false;
50
+
51
+ private exhausted = false;
52
+
53
+ private lastFrameAt = 0;
54
+
55
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
56
+
57
+ private livenessTimer: ReturnType<typeof setInterval> | null = null;
58
+
59
+ private stopWatchingForeground: (() => void) | null = null;
60
+
61
+ private readonly cache = new ResponseCache();
62
+
63
+ private cacheScope: string | null = null;
64
+
65
+ constructor(private readonly options: SessionSocketOptions) {}
66
+
67
+ connect(sessionId: number): void {
68
+ const scope = `${sessionId}:${this.options.getSourceId() ?? ''}`;
69
+ if (scope !== this.cacheScope) {
70
+ this.cache.clear();
71
+ this.cacheScope = scope;
72
+ }
73
+ this.sessionId = sessionId;
74
+ this.intentionallyClosed = false;
75
+ this.epoch += 1;
76
+ this.clearTimers();
77
+ this.discardSocket();
78
+ this.watchForeground();
79
+ this.options.onState(this.attempts === 0 ? 'connecting' : 'reconnecting');
80
+ const epoch = this.epoch;
81
+ const pending = this.options.getProof();
82
+ if (pending === null) {
83
+ this.open(epoch, null);
84
+ return;
85
+ }
86
+ void pending
87
+ .catch(() => null)
88
+ .then((proof) => {
89
+ if (this.epoch === epoch && !this.intentionallyClosed) {
90
+ this.open(epoch, proof);
91
+ }
92
+ });
93
+ }
94
+
95
+ disconnect(): void {
96
+ this.intentionallyClosed = true;
97
+ this.epoch += 1;
98
+ this.clearTimers();
99
+ this.stopWatchingForeground?.();
100
+ this.stopWatchingForeground = null;
101
+ this.discardSocket();
102
+ this.attempts = 0;
103
+ this.options.onState('disconnected');
104
+ }
105
+
106
+ isOpen(): boolean {
107
+ return this.socket !== null && this.socket.readyState === WebSocket.OPEN;
108
+ }
109
+
110
+ send(payload: string): boolean {
111
+ if (!this.isOpen()) {
112
+ return false;
113
+ }
114
+ this.socket?.send(payload);
115
+ return true;
116
+ }
117
+
118
+ sendOrCache(commandId: string, payload: string): void {
119
+ if (!this.send(payload)) {
120
+ this.cache.store(commandId, payload);
121
+ }
122
+ }
123
+
124
+ private open(epoch: number, proof: IdentityProof | null): void {
125
+ const sourceId = this.options.getSourceId();
126
+ if (this.sessionId === null || sourceId === null) {
127
+ this.options.onState('failed');
128
+ return;
129
+ }
130
+ let socket: WebSocket;
131
+ try {
132
+ const url = buildSocketUrl({
133
+ apiBaseUrl: this.options.apiBaseUrl,
134
+ sessionId: this.sessionId,
135
+ publishableKey: this.options.publishableKey,
136
+ sourceId,
137
+ tabId: this.options.tabId,
138
+ lastSeenSeq: this.options.getLastSeenSeq(),
139
+ });
140
+ if (url === null || (proof !== null && (proof.protocol !== proof.protocol.trim() || !/^[!#$%&'*+.^_`|~a-z0-9-]+$/i.test(proof.protocol)))) {
141
+ this.disconnect();
142
+ this.options.onState('failed');
143
+ return;
144
+ }
145
+ socket = proof === null ? new WebSocket(url) : new WebSocket(url, [proof.protocol]);
146
+ } catch {
147
+ this.disconnect();
148
+ this.options.onState('failed');
149
+ return;
150
+ }
151
+ this.socket = socket;
152
+ socket.onopen = () => {
153
+ if (this.epoch !== epoch) {
154
+ return;
155
+ }
156
+ this.attempts = 0;
157
+ this.exhausted = false;
158
+ this.startLiveness();
159
+ this.options.onState('connected');
160
+ socket.send(JSON.stringify({ type: 'joined' }));
161
+ this.cache.drain().forEach((payload) => socket.send(payload));
162
+ };
163
+ socket.onmessage = (event) => {
164
+ if (this.epoch !== epoch || typeof event.data !== 'string') {
165
+ return;
166
+ }
167
+ this.lastFrameAt = Date.now();
168
+ const frame = parseFrame(event.data);
169
+ if (frame?.type === 'history') {
170
+ this.identityRefreshUsed = false;
171
+ }
172
+ this.options.onMessage(event.data, frame);
173
+ };
174
+ socket.onerror = () => undefined;
175
+ socket.onclose = (event) => {
176
+ if (this.epoch === epoch) {
177
+ this.handleClose(epoch, event.code ?? STALE_CLOSE_CODE);
178
+ }
179
+ };
180
+ }
181
+
182
+ private handleClose(epoch: number, code: number): void {
183
+ this.stopLiveness();
184
+ this.options.onState('disconnected');
185
+ if (this.intentionallyClosed) {
186
+ return;
187
+ }
188
+ if (code === IDENTITY_REJECTED_CLOSE_CODE) {
189
+ this.handleIdentityRejection(epoch);
190
+ return;
191
+ }
192
+ this.scheduleReconnect();
193
+ }
194
+
195
+ private handleIdentityRejection(epoch: number): void {
196
+ if (this.identityRefreshUsed) {
197
+ this.options.onState('failed');
198
+ this.options.onIdentityRejected();
199
+ return;
200
+ }
201
+ this.identityRefreshUsed = true;
202
+ void this.options.refreshProof().finally(() => {
203
+ if (this.epoch === epoch && !this.intentionallyClosed) {
204
+ this.scheduleReconnect();
205
+ }
206
+ });
207
+ }
208
+
209
+ private scheduleReconnect(): void {
210
+ if (isExhausted(SOCKET_BACKOFF, this.attempts)) {
211
+ this.options.onState('failed');
212
+ this.exhausted = true;
213
+ this.reconnectTimer = setTimeout(() => this.recover(), EXHAUSTED_RETRY_MS);
214
+ return;
215
+ }
216
+ const delay = backoffDelay(SOCKET_BACKOFF, this.attempts);
217
+ this.options.onState('reconnecting');
218
+ this.reconnectTimer = setTimeout(() => {
219
+ this.attempts += 1;
220
+ if (this.sessionId !== null) {
221
+ this.connect(this.sessionId);
222
+ }
223
+ }, delay);
224
+ }
225
+
226
+ private recover(): void {
227
+ if (!this.exhausted || this.intentionallyClosed || this.sessionId === null) {
228
+ return;
229
+ }
230
+ this.attempts = 0;
231
+ this.connect(this.sessionId);
232
+ }
233
+
234
+ private watchForeground(): void {
235
+ if (this.stopWatchingForeground === null) {
236
+ this.stopWatchingForeground = this.options.watchForeground(() => this.recover());
237
+ }
238
+ }
239
+
240
+ private startLiveness(): void {
241
+ this.stopLiveness();
242
+ this.lastFrameAt = Date.now();
243
+ this.livenessTimer = setInterval(() => {
244
+ if (Date.now() - this.lastFrameAt >= STALE_AFTER_MS) {
245
+ this.dropStaleSocket();
246
+ }
247
+ }, LIVENESS_CHECK_MS);
248
+ }
249
+
250
+ private stopLiveness(): void {
251
+ if (this.livenessTimer !== null) {
252
+ clearInterval(this.livenessTimer);
253
+ this.livenessTimer = null;
254
+ }
255
+ }
256
+
257
+ private dropStaleSocket(): void {
258
+ const socket = this.socket;
259
+ if (socket === null || this.intentionallyClosed) {
260
+ return;
261
+ }
262
+ this.detach(socket);
263
+ socket.close();
264
+ this.socket = null;
265
+ this.handleClose(this.epoch, STALE_CLOSE_CODE);
266
+ }
267
+
268
+ private discardSocket(): void {
269
+ const socket = this.socket;
270
+ this.detach(socket);
271
+ socket?.close(1000, 'Client disconnected');
272
+ this.socket = null;
273
+ }
274
+
275
+ private detach(socket: WebSocket | null): void {
276
+ if (socket === null) {
277
+ return;
278
+ }
279
+ socket.onopen = null;
280
+ socket.onmessage = null;
281
+ socket.onerror = null;
282
+ socket.onclose = null;
283
+ }
284
+
285
+ private clearTimers(): void {
286
+ this.stopLiveness();
287
+ if (this.reconnectTimer !== null) {
288
+ clearTimeout(this.reconnectTimer);
289
+ this.reconnectTimer = null;
290
+ }
291
+ }
292
+ }
@@ -0,0 +1,188 @@
1
+ import type { ApiClient } from './api-client.ts';
2
+ import type { BootstrapResponse, IdentifyRequest } from './api-types.ts';
3
+ import type { BlarIdentity, IdentityRefusalReason } from './identity.ts';
4
+ import type { KeyValueStorage } from './storage.ts';
5
+
6
+ export interface SessionState {
7
+ sourceId: string | null;
8
+ sessionId: number | null;
9
+ conversationId: number | null;
10
+ aiActive: boolean;
11
+ identified: boolean;
12
+ isIdentityVerified: boolean;
13
+ }
14
+
15
+ type Listener = (state: SessionState) => void;
16
+
17
+ const EMPTY_STATE: SessionState = {
18
+ sourceId: null,
19
+ sessionId: null,
20
+ conversationId: null,
21
+ aiActive: true,
22
+ identified: false,
23
+ isIdentityVerified: false,
24
+ };
25
+
26
+ function storageKeyFor(publishableKey: string): string {
27
+ return `blar_source_id_${publishableKey.slice(-8)}`;
28
+ }
29
+
30
+ function buildIdentifyRequest(identity: BlarIdentity): IdentifyRequest {
31
+ return {
32
+ external_id: identity.externalId,
33
+ ...(identity.email === undefined ? {} : { email: identity.email }),
34
+ ...(identity.name === undefined ? {} : { name: identity.name }),
35
+ ...(identity.phone === undefined ? {} : { phone: identity.phone }),
36
+ ...(identity.contactCompany === undefined ? {} : { contact_company: identity.contactCompany }),
37
+ ...(identity.contactCompanyExternalId === undefined ? {} : { contact_company_external_id: identity.contactCompanyExternalId }),
38
+ ...(identity.country === undefined ? {} : { country: identity.country }),
39
+ ...(identity.customFields === undefined ? {} : { custom_fields: identity.customFields }),
40
+ };
41
+ }
42
+
43
+ export class BackendSession {
44
+ private state: SessionState = EMPTY_STATE;
45
+
46
+ private readonly listeners = new Set<Listener>();
47
+
48
+ private restoring: Promise<string | null> | null = null;
49
+
50
+ private readonly storageKey: string;
51
+
52
+ constructor(
53
+ private readonly api: ApiClient,
54
+ private readonly storage: KeyValueStorage,
55
+ ) {
56
+ this.storageKey = storageKeyFor(api.getPublishableKey());
57
+ api.setHooks({
58
+ restoreSource: () => this.restoreSession(),
59
+ onIdentityRefused: (reason) => void this.fallBackToAnonymous(reason),
60
+ });
61
+ }
62
+
63
+ getState(): SessionState {
64
+ return this.state;
65
+ }
66
+
67
+ subscribe(listener: Listener): () => void {
68
+ this.listeners.add(listener);
69
+ return () => this.listeners.delete(listener);
70
+ }
71
+
72
+ async boot(identity: BlarIdentity | null): Promise<void> {
73
+ this.api.setIdentity(identity);
74
+ const stored = await this.storage.getItem(this.storageKey);
75
+ this.api.setSourceId(stored);
76
+ if (identity === null || stored === null) {
77
+ await this.loadAnonymous();
78
+ }
79
+ if (identity !== null) {
80
+ await this.identify(identity).catch(() => this.api.setIdentity(null));
81
+ }
82
+ }
83
+
84
+ async setUser(identity: BlarIdentity): Promise<void> {
85
+ this.api.setIdentity(identity);
86
+ if (this.api.getSourceId() === null) {
87
+ await this.loadAnonymous();
88
+ }
89
+ await this.identify(identity);
90
+ }
91
+
92
+ private async identify(identity: BlarIdentity): Promise<void> {
93
+ try {
94
+ this.apply(await this.api.identifyUser(buildIdentifyRequest(identity)), true);
95
+ } catch (error) {
96
+ this.api.setIdentity(null);
97
+ if (this.api.getSourceId() === null) {
98
+ await this.restoreSession();
99
+ }
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ async logout(): Promise<void> {
105
+ this.api.setIdentity(null);
106
+ await this.dropSource();
107
+ await this.loadAnonymous();
108
+ }
109
+
110
+ async refresh(): Promise<SessionState> {
111
+ await this.loadAnonymous();
112
+ const identity = this.api.getIdentity();
113
+ if (identity !== null) {
114
+ await this.identify(identity).catch(() => undefined);
115
+ }
116
+ return this.state;
117
+ }
118
+
119
+ adoptSession(sessionId: number): void {
120
+ this.update({ ...this.state, sessionId });
121
+ }
122
+
123
+ leaveSession(): void {
124
+ this.update({ ...this.state, sessionId: null });
125
+ }
126
+
127
+ async ensureSessionId(): Promise<number | null> {
128
+ if (this.state.sessionId !== null) {
129
+ return this.state.sessionId;
130
+ }
131
+ const sessionId = await this.api.createSession();
132
+ this.update({ ...this.state, sessionId });
133
+ return sessionId;
134
+ }
135
+
136
+ private async loadAnonymous(): Promise<void> {
137
+ this.apply(await this.api.loadSession(this.api.getSourceId()), false);
138
+ }
139
+
140
+ private restoreSession(): Promise<string | null> {
141
+ this.restoring = this.restoring ?? this.restore().finally(() => {
142
+ this.restoring = null;
143
+ });
144
+ return this.restoring;
145
+ }
146
+
147
+ private async restore(): Promise<string | null> {
148
+ const identity = this.api.getIdentity();
149
+ try {
150
+ await this.loadAnonymous();
151
+ if (identity !== null) {
152
+ await this.setUser(identity).catch(() => undefined);
153
+ }
154
+ return this.api.getSourceId();
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ private async fallBackToAnonymous(_reason: IdentityRefusalReason): Promise<void> {
161
+ this.api.setIdentity(null);
162
+ await this.dropSource();
163
+ }
164
+
165
+ private async dropSource(): Promise<void> {
166
+ this.api.setSourceId(null);
167
+ await this.storage.removeItem(this.storageKey);
168
+ this.update(EMPTY_STATE);
169
+ }
170
+
171
+ private apply(bootstrap: BootstrapResponse, identified: boolean): void {
172
+ this.api.setSourceId(bootstrap.sourceId);
173
+ void this.storage.setItem(this.storageKey, bootstrap.sourceId);
174
+ this.update({
175
+ sourceId: bootstrap.sourceId,
176
+ sessionId: bootstrap.sessionId,
177
+ conversationId: bootstrap.conversationId,
178
+ aiActive: bootstrap.aiActive,
179
+ identified,
180
+ isIdentityVerified: bootstrap.isIdentityVerified,
181
+ });
182
+ }
183
+
184
+ private update(next: SessionState): void {
185
+ this.state = next;
186
+ this.listeners.forEach((listener) => listener(next));
187
+ }
188
+ }
@@ -0,0 +1,25 @@
1
+ import { normalizeApiBaseUrl } from '../transport/connection-options.ts';
2
+
3
+ export interface SocketUrlParts {
4
+ apiBaseUrl: string;
5
+ sessionId: number;
6
+ publishableKey: string;
7
+ sourceId: string;
8
+ tabId: string;
9
+ lastSeenSeq: number;
10
+ }
11
+
12
+ export function buildSocketUrl(parts: SocketUrlParts): string | null {
13
+ const normalized = normalizeApiBaseUrl(parts.apiBaseUrl);
14
+ if (normalized === null) {
15
+ return null;
16
+ }
17
+ const base = normalized.replace(/^http/, 'ws');
18
+ const query = [
19
+ `publishable_key=${encodeURIComponent(parts.publishableKey)}`,
20
+ `source_id=${encodeURIComponent(parts.sourceId)}`,
21
+ `tab_id=${encodeURIComponent(parts.tabId)}`,
22
+ ...(parts.lastSeenSeq > 0 ? [`last_seen_seq=${parts.lastSeenSeq}`] : []),
23
+ ];
24
+ return `${base}/sessions/chat/${parts.sessionId}/?${query.join('&')}`;
25
+ }
@@ -0,0 +1,49 @@
1
+ import { isFunction, isRecord } from '../fiber/narrow.ts';
2
+
3
+ export interface KeyValueStorage {
4
+ getItem: (key: string) => Promise<string | null>;
5
+ setItem: (key: string, value: string) => Promise<void>;
6
+ removeItem: (key: string) => Promise<void>;
7
+ }
8
+
9
+ declare const require: (moduleName: string) => unknown;
10
+
11
+ class MemoryStorage implements KeyValueStorage {
12
+ private readonly values = new Map<string, string>();
13
+
14
+ getItem(key: string): Promise<string | null> {
15
+ return Promise.resolve(this.values.get(key) ?? null);
16
+ }
17
+
18
+ setItem(key: string, value: string): Promise<void> {
19
+ this.values.set(key, value);
20
+ return Promise.resolve();
21
+ }
22
+
23
+ removeItem(key: string): Promise<void> {
24
+ this.values.delete(key);
25
+ return Promise.resolve();
26
+ }
27
+ }
28
+
29
+ function isKeyValueStorage(value: unknown): value is KeyValueStorage {
30
+ return isRecord(value) && isFunction(value.getItem) && isFunction(value.setItem) && isFunction(value.removeItem);
31
+ }
32
+
33
+ function loadAsyncStorage(): KeyValueStorage | null {
34
+ try {
35
+ const loaded = require('@react-native-async-storage/async-storage');
36
+ const candidate = isRecord(loaded) && 'default' in loaded ? loaded.default : loaded;
37
+ return isKeyValueStorage(candidate) ? candidate : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ const ASYNC_STORAGE = loadAsyncStorage();
44
+
45
+ export function createDefaultStorage(): KeyValueStorage {
46
+ return ASYNC_STORAGE ?? new MemoryStorage();
47
+ }
48
+
49
+ export { MemoryStorage };