@userflow-tech/react-native 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +24 -14
  2. package/app.plugin.js +3 -0
  3. package/babel-plugin/index.d.ts +43 -0
  4. package/babel-plugin/index.d.ts.map +1 -0
  5. package/babel-plugin/index.js +854 -0
  6. package/babel-plugin/index.js.map +1 -0
  7. package/docs/deep-linking.md +2 -2
  8. package/docs/troubleshooting.md +2 -2
  9. package/expo-plugin/withUserflow.d.ts +17 -0
  10. package/expo-plugin/withUserflow.d.ts.map +1 -0
  11. package/expo-plugin/withUserflow.js +99 -0
  12. package/expo-plugin/withUserflow.js.map +1 -0
  13. package/lib/commonjs/anchors/AutoAnchorRegistry.js +1 -1
  14. package/lib/commonjs/anchors/autoAnchor.js +1 -1
  15. package/lib/commonjs/anchors/hintsCodec.js +1 -1
  16. package/lib/commonjs/pairing/diagnostics.js +2 -2
  17. package/lib/commonjs/pairing/diagnostics.js.map +1 -1
  18. package/lib/commonjs/version.js +1 -1
  19. package/lib/module/anchors/AutoAnchorRegistry.js +1 -1
  20. package/lib/module/anchors/autoAnchor.js +1 -1
  21. package/lib/module/anchors/hintsCodec.js +1 -1
  22. package/lib/module/pairing/diagnostics.js +2 -2
  23. package/lib/module/pairing/diagnostics.js.map +1 -1
  24. package/lib/module/version.js +1 -1
  25. package/lib/typescript/commonjs/src/anchors/AutoAnchorRegistry.d.ts +1 -1
  26. package/lib/typescript/commonjs/src/anchors/AutoAnchorRegistry.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/src/anchors/autoAnchor.d.ts +1 -1
  28. package/lib/typescript/commonjs/src/anchors/hintsCodec.d.ts +1 -1
  29. package/lib/typescript/commonjs/src/version.d.ts +1 -1
  30. package/lib/typescript/module/src/anchors/AutoAnchorRegistry.d.ts +1 -1
  31. package/lib/typescript/module/src/anchors/AutoAnchorRegistry.d.ts.map +1 -1
  32. package/lib/typescript/module/src/anchors/autoAnchor.d.ts +1 -1
  33. package/lib/typescript/module/src/anchors/hintsCodec.d.ts +1 -1
  34. package/lib/typescript/module/src/version.d.ts +1 -1
  35. package/package.json +15 -7
  36. package/src/anchors/AutoAnchorRegistry.ts +1 -1
  37. package/src/anchors/autoAnchor.ts +1 -1
  38. package/src/anchors/hintsCodec.ts +1 -1
  39. package/src/pairing/diagnostics.ts +2 -2
  40. package/src/version.ts +1 -1
  41. package/vendor/core/LICENSE +76 -0
  42. package/vendor/core/README.md +9 -0
  43. package/vendor/core/dist/attributes.d.ts +16 -0
  44. package/vendor/core/dist/attributes.d.ts.map +1 -0
  45. package/vendor/core/dist/attributes.js +55 -0
  46. package/vendor/core/dist/attributes.js.map +1 -0
  47. package/vendor/core/dist/constants.d.ts +21 -0
  48. package/vendor/core/dist/constants.d.ts.map +1 -0
  49. package/vendor/core/dist/constants.js +21 -0
  50. package/vendor/core/dist/constants.js.map +1 -0
  51. package/vendor/core/dist/engine/ClientConditionEngine.d.ts +68 -0
  52. package/vendor/core/dist/engine/ClientConditionEngine.d.ts.map +1 -0
  53. package/vendor/core/dist/engine/ClientConditionEngine.js +197 -0
  54. package/vendor/core/dist/engine/ClientConditionEngine.js.map +1 -0
  55. package/vendor/core/dist/engine/FlowSessionEngine.d.ts +92 -0
  56. package/vendor/core/dist/engine/FlowSessionEngine.d.ts.map +1 -0
  57. package/vendor/core/dist/engine/FlowSessionEngine.js +310 -0
  58. package/vendor/core/dist/engine/FlowSessionEngine.js.map +1 -0
  59. package/vendor/core/dist/engine/PairingEngine.d.ts +92 -0
  60. package/vendor/core/dist/engine/PairingEngine.d.ts.map +1 -0
  61. package/vendor/core/dist/engine/PairingEngine.js +302 -0
  62. package/vendor/core/dist/engine/PairingEngine.js.map +1 -0
  63. package/vendor/core/dist/engine/UserflowCore.d.ts +126 -0
  64. package/vendor/core/dist/engine/UserflowCore.d.ts.map +1 -0
  65. package/vendor/core/dist/engine/UserflowCore.js +266 -0
  66. package/vendor/core/dist/engine/UserflowCore.js.map +1 -0
  67. package/vendor/core/dist/engine/handleActions.d.ts +19 -0
  68. package/vendor/core/dist/engine/handleActions.d.ts.map +1 -0
  69. package/vendor/core/dist/engine/handleActions.js +71 -0
  70. package/vendor/core/dist/engine/handleActions.js.map +1 -0
  71. package/vendor/core/dist/engine/interpolateSessionData.d.ts +7 -0
  72. package/vendor/core/dist/engine/interpolateSessionData.d.ts.map +1 -0
  73. package/vendor/core/dist/engine/interpolateSessionData.js +29 -0
  74. package/vendor/core/dist/engine/interpolateSessionData.js.map +1 -0
  75. package/vendor/core/dist/errors.d.ts +9 -0
  76. package/vendor/core/dist/errors.d.ts.map +1 -0
  77. package/vendor/core/dist/errors.js +15 -0
  78. package/vendor/core/dist/errors.js.map +1 -0
  79. package/vendor/core/dist/eventAttributes.d.ts +7 -0
  80. package/vendor/core/dist/eventAttributes.d.ts.map +1 -0
  81. package/vendor/core/dist/eventAttributes.js +31 -0
  82. package/vendor/core/dist/eventAttributes.js.map +1 -0
  83. package/vendor/core/dist/index.d.ts +30 -0
  84. package/vendor/core/dist/index.d.ts.map +1 -0
  85. package/vendor/core/dist/index.js +21 -0
  86. package/vendor/core/dist/index.js.map +1 -0
  87. package/vendor/core/dist/transport/backoff.d.ts +11 -0
  88. package/vendor/core/dist/transport/backoff.d.ts.map +1 -0
  89. package/vendor/core/dist/transport/backoff.js +17 -0
  90. package/vendor/core/dist/transport/backoff.js.map +1 -0
  91. package/vendor/core/dist/transport/pairing.d.ts +121 -0
  92. package/vendor/core/dist/transport/pairing.d.ts.map +1 -0
  93. package/vendor/core/dist/transport/pairing.js +551 -0
  94. package/vendor/core/dist/transport/pairing.js.map +1 -0
  95. package/vendor/core/dist/transport/phoenix.d.ts +223 -0
  96. package/vendor/core/dist/transport/phoenix.d.ts.map +1 -0
  97. package/vendor/core/dist/transport/phoenix.js +757 -0
  98. package/vendor/core/dist/transport/phoenix.js.map +1 -0
  99. package/vendor/core/dist/transport/rateLimiter.d.ts +17 -0
  100. package/vendor/core/dist/transport/rateLimiter.d.ts.map +1 -0
  101. package/vendor/core/dist/transport/rateLimiter.js +30 -0
  102. package/vendor/core/dist/transport/rateLimiter.js.map +1 -0
  103. package/vendor/core/dist/transport/scheduler.d.ts +12 -0
  104. package/vendor/core/dist/transport/scheduler.d.ts.map +1 -0
  105. package/vendor/core/dist/transport/scheduler.js +11 -0
  106. package/vendor/core/dist/transport/scheduler.js.map +1 -0
  107. package/vendor/core/dist/types.d.ts +35 -0
  108. package/vendor/core/dist/types.d.ts.map +1 -0
  109. package/vendor/core/dist/types.js +2 -0
  110. package/vendor/core/dist/types.js.map +1 -0
  111. package/vendor/core/dist/version.d.ts +3 -0
  112. package/vendor/core/dist/version.d.ts.map +1 -0
  113. package/vendor/core/dist/version.js +3 -0
  114. package/vendor/core/dist/version.js.map +1 -0
  115. package/vendor/core/package.json +20 -0
  116. package/vendor/protocol/LICENSE +76 -0
  117. package/vendor/protocol/README.md +8 -0
  118. package/vendor/protocol/dist/autoData.d.ts +232 -0
  119. package/vendor/protocol/dist/autoData.d.ts.map +1 -0
  120. package/vendor/protocol/dist/autoData.js +78 -0
  121. package/vendor/protocol/dist/autoData.js.map +1 -0
  122. package/vendor/protocol/dist/events.d.ts +16 -0
  123. package/vendor/protocol/dist/events.d.ts.map +1 -0
  124. package/vendor/protocol/dist/events.js +8 -0
  125. package/vendor/protocol/dist/events.js.map +1 -0
  126. package/vendor/protocol/dist/flows.d.ts +298 -0
  127. package/vendor/protocol/dist/flows.d.ts.map +1 -0
  128. package/vendor/protocol/dist/flows.js +154 -0
  129. package/vendor/protocol/dist/flows.js.map +1 -0
  130. package/vendor/protocol/dist/index.d.ts +24 -0
  131. package/vendor/protocol/dist/index.d.ts.map +1 -0
  132. package/vendor/protocol/dist/index.js +16 -0
  133. package/vendor/protocol/dist/index.js.map +1 -0
  134. package/vendor/protocol/dist/messages.d.ts +166 -0
  135. package/vendor/protocol/dist/messages.d.ts.map +1 -0
  136. package/vendor/protocol/dist/messages.js +57 -0
  137. package/vendor/protocol/dist/messages.js.map +1 -0
  138. package/vendor/protocol/dist/pairing.d.ts +56 -0
  139. package/vendor/protocol/dist/pairing.d.ts.map +1 -0
  140. package/vendor/protocol/dist/pairing.js +146 -0
  141. package/vendor/protocol/dist/pairing.js.map +1 -0
  142. package/vendor/protocol/package.json +17 -0
@@ -0,0 +1,757 @@
1
+ import { isServerMessage, TargetPlatform } from '@userflow-tech/protocol';
2
+ import { UserflowAuthError, UserflowRateLimitError } from '../errors.js';
3
+ import { CORE_VERSION } from '../version.js';
4
+ import { createBackoff } from './backoff.js';
5
+ import { createRateLimiter, RATE_LIMIT_PER_MINUTE } from './rateLimiter.js';
6
+ import { defaultScheduler } from './scheduler.js';
7
+ const TIMEOUT_MS = 15000;
8
+ const HEARTBEAT_INTERVAL_MS = 30000;
9
+ /**
10
+ * How long to wait for a heartbeat reply before declaring the socket dead.
11
+ * A short, bounded liveness window (vs. waiting a full heartbeat interval)
12
+ * means a silently-dropped socket is detected in seconds instead of up to two
13
+ * intervals. Capped at the heartbeat interval so tiny test intervals behave.
14
+ */
15
+ const HEARTBEAT_REPLY_TIMEOUT_MS = 10000;
16
+ /** WebSocket.OPEN readyState, referenced without the DOM/global type. */
17
+ const WS_OPEN = 1;
18
+ const PHOENIX_TOPIC = 'phoenix';
19
+ const DEFAULT_CLIENT_CONTEXT = {
20
+ pageUrl: '',
21
+ viewportWidth: 390,
22
+ viewportHeight: 844,
23
+ };
24
+ /** Builds the Phoenix websocket URL for a given endpoint and client token. */
25
+ export function buildWsUrl(serverEndpoint, clientToken) {
26
+ const host = serverEndpoint.replace(/\/$/, '');
27
+ const path = `/end-users/${encodeURIComponent(clientToken)}/socket/websocket?vsn=2.0.0`;
28
+ if (host.startsWith('http://'))
29
+ return `ws://${host.slice('http://'.length)}${path}`;
30
+ if (host.startsWith('https://'))
31
+ return `wss://${host.slice('https://'.length)}${path}`;
32
+ if (host.startsWith('ws://') || host.startsWith('wss://'))
33
+ return `${host}${path}`;
34
+ return `wss://${host}${path}`;
35
+ }
36
+ function uuid() {
37
+ const webcrypto = globalThis.crypto;
38
+ if (webcrypto?.randomUUID)
39
+ return webcrypto.randomUUID();
40
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char) => {
41
+ const rand = (Math.random() * 16) | 0;
42
+ const value = char === 'x' ? rand : (rand & 0x3) | 0x8;
43
+ return value.toString(16);
44
+ });
45
+ }
46
+ function endUserJoinPayload(signature = null, flowSessionId = null, clientContext = DEFAULT_CLIENT_CONTEXT, clientConditions = []) {
47
+ return {
48
+ protocolVersion: 2,
49
+ userflowClientBuild: `mobile-${CORE_VERSION}`,
50
+ signature,
51
+ groupExternalId: null,
52
+ groupSignature: null,
53
+ flowSessionId,
54
+ checklistSessionId: null,
55
+ resourceCenterSessionId: null,
56
+ launchers: [],
57
+ trackers: [],
58
+ hasDraftSession: false,
59
+ clientConditions,
60
+ clientContext,
61
+ // Without this the server treats the socket as a web client and
62
+ // check_sessions immediately unsets any React Native draft preview.
63
+ targetPlatform: TargetPlatform.REACT_NATIVE,
64
+ };
65
+ }
66
+ function isSignatureRequiredError(error) {
67
+ return error instanceof UserflowAuthError && error.message.includes('user_signature_required');
68
+ }
69
+ function joinError(response) {
70
+ if (response && typeof response === 'object') {
71
+ const err = response;
72
+ if (err.code && err.message)
73
+ return `${err.code}: ${err.message}`;
74
+ if (err.message)
75
+ return err.message;
76
+ }
77
+ return 'channel join rejected';
78
+ }
79
+ function resolveSocketCtor(ctor) {
80
+ const provided = ctor ?? globalThis.WebSocket;
81
+ if (!provided) {
82
+ throw new UserflowAuthError('No WebSocket implementation is available');
83
+ }
84
+ return provided;
85
+ }
86
+ /**
87
+ * A resilient Phoenix websocket connection to the Userflow end-user endpoint.
88
+ * It speaks the v2 serialized frame protocol (`[joinRef, ref, topic, event,
89
+ * payload]`), tracks an explicit lifecycle, auto-reconnects with backoff,
90
+ * heartbeats to keep the socket alive, queues outbound pushes while
91
+ * disconnected, and can be suspended/resumed for app background handling.
92
+ *
93
+ * Liveness: each heartbeat arms a short reply deadline so a silently-dropped
94
+ * socket is detected in seconds rather than up to two heartbeat intervals.
95
+ * {@link ensureAlive} fires an immediate probe (e.g. on foreground or when the
96
+ * host reports connectivity restored) so recovery does not wait for the next
97
+ * scheduled heartbeat. This keeps recovery fast with zero external
98
+ * dependencies; a host-provided network signal that calls `ensureAlive` is an
99
+ * optional optimization, not a requirement.
100
+ */
101
+ export class PhoenixConnection {
102
+ constructor(clientToken, serverEndpoint, socketCtor, options) {
103
+ this.clientToken = clientToken;
104
+ this.serverEndpoint = serverEndpoint;
105
+ this.socketCtor = socketCtor;
106
+ this.ws = null;
107
+ this.ref = 0;
108
+ this.pending = new Map();
109
+ this.channelJoinRef = null;
110
+ this.channelTopic = null;
111
+ this.channelJoined = false;
112
+ this.state = 'disconnected';
113
+ this.shouldBeConnected = false;
114
+ this.desiredTopic = null;
115
+ this.desiredJoinSignature = null;
116
+ this.outbound = [];
117
+ this.establishPromise = null;
118
+ this.pendingConnect = null;
119
+ this.reconnectHandle = null;
120
+ this.heartbeatHandle = null;
121
+ this.heartbeatReplyHandle = null;
122
+ this.pendingHeartbeatRef = null;
123
+ this.activeFlowSessionId = null;
124
+ this.clientContext = { ...DEFAULT_CLIENT_CONTEXT };
125
+ this.clientConditions = [];
126
+ this.serverMessageHandlers = new Set();
127
+ /** Kinds already warned about this connection lifetime (reset on disconnect). */
128
+ this.warnedUnhandledKinds = new Set();
129
+ this.scheduler = options?.scheduler ?? defaultScheduler;
130
+ this.backoff = createBackoff(options?.reconnectLadderMs);
131
+ this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
132
+ this.heartbeatReplyTimeoutMs =
133
+ options?.heartbeatReplyTimeoutMs ??
134
+ Math.min(HEARTBEAT_REPLY_TIMEOUT_MS, this.heartbeatIntervalMs);
135
+ this.rateLimitCapacity = options?.rateLimit?.capacity ?? RATE_LIMIT_PER_MINUTE;
136
+ this.rateLimiter = createRateLimiter({
137
+ ...(options?.rateLimit?.capacity !== undefined
138
+ ? { capacity: options.rateLimit.capacity }
139
+ : {}),
140
+ ...(options?.rateLimit?.intervalMs !== undefined
141
+ ? { intervalMs: options.rateLimit.intervalMs }
142
+ : {}),
143
+ ...(options?.rateLimit?.now !== undefined ? { now: options.rateLimit.now } : {}),
144
+ });
145
+ this.warn = options?.warn ?? ((message) => console.warn(message));
146
+ }
147
+ /** The current lifecycle state (observability + tests). */
148
+ getState() {
149
+ return this.state;
150
+ }
151
+ /**
152
+ * Subscribe to inbound `server_message` frames on the joined end-user channel.
153
+ * Returns an unsubscribe function. Unknown message kinds are ignored (warned
154
+ * once per kind per connection lifetime).
155
+ */
156
+ onServerMessage(handler) {
157
+ this.serverMessageHandlers.add(handler);
158
+ return () => {
159
+ this.serverMessageHandlers.delete(handler);
160
+ };
161
+ }
162
+ /**
163
+ * Remember the active flow session id so every (re)join can resume it.
164
+ * Pass `null` when the session ends.
165
+ */
166
+ setFlowSessionId(flowSessionId) {
167
+ this.activeFlowSessionId = flowSessionId;
168
+ }
169
+ /** The flow session id currently included in end-user join payloads. */
170
+ getFlowSessionId() {
171
+ return this.activeFlowSessionId;
172
+ }
173
+ /**
174
+ * Remember the client-condition snapshot so every (re)join can seed the
175
+ * server's tracked/true/false sets. Pass the current engine snapshot
176
+ * (including `isTrue: null` unknowns).
177
+ */
178
+ setClientConditions(clientConditions) {
179
+ this.clientConditions = clientConditions.map((entry) => ({ ...entry }));
180
+ }
181
+ /** The client-condition snapshot currently included in end-user join payloads. */
182
+ getClientConditions() {
183
+ return this.clientConditions.map((entry) => ({ ...entry }));
184
+ }
185
+ /** The client context currently included in end-user join payloads. */
186
+ getClientContext() {
187
+ return { ...this.clientContext };
188
+ }
189
+ /**
190
+ * Remember client context so every (re)join reports the latest screen/URL.
191
+ * Does not push to the server; use {@link updateClientContext} for that.
192
+ */
193
+ setClientContext(clientContext) {
194
+ this.clientContext = { ...clientContext };
195
+ }
196
+ /**
197
+ * Update remembered client context and push `UpdateClientContext` when joined.
198
+ * Mirrors the web client's URL-change → `pushUpdateClientContext` path.
199
+ */
200
+ updateClientContext(clientContext) {
201
+ this.clientContext = { ...clientContext };
202
+ if (!this.channelTopic || !this.channelJoined) {
203
+ return Promise.resolve();
204
+ }
205
+ return this.enqueuePush('client_message', {
206
+ kind: 'UpdateClientContext',
207
+ clientContext: this.clientContext,
208
+ });
209
+ }
210
+ /**
211
+ * Push a Phase 3 client message on the joined channel. Reuses the outbound
212
+ * queue. `ToggleClientCondition` skips the rate limiter (web parity: meta
213
+ * messages must not be starved by UpsertUser / GoToStep traffic, and
214
+ * condition churn must not fail closed until the next rejoin).
215
+ */
216
+ send(message) {
217
+ if (!this.channelTopic || !this.channelJoined) {
218
+ return Promise.reject(new UserflowAuthError('Not joined to an end-user channel'));
219
+ }
220
+ return this.enqueuePush('client_message', message, {
221
+ skipRateLimit: message.kind === 'ToggleClientCondition',
222
+ });
223
+ }
224
+ /** Validates the client token by probing an anonymous end-user channel join. */
225
+ async validate() {
226
+ await this.openSocket();
227
+ try {
228
+ try {
229
+ await this.join(`end_users:anon-${uuid()}`, endUserJoinPayload());
230
+ }
231
+ catch (error) {
232
+ if (!isSignatureRequiredError(error))
233
+ throw error;
234
+ }
235
+ }
236
+ finally {
237
+ this.disconnect();
238
+ }
239
+ }
240
+ /** Joins the end-user channel for `userId` and upserts the given attributes. */
241
+ async identify(userId, attributes, signature = null) {
242
+ this.shouldBeConnected = true;
243
+ this.desiredJoinSignature = signature;
244
+ await this.ensureJoined(`end_users:${userId}`);
245
+ await this.enqueuePush('client_message', { kind: 'UpsertUser', attributes });
246
+ }
247
+ /** Joins (or re-joins) the end-user channel when the socket dropped since identify. */
248
+ async ensureEndUserChannel(userId, signature = null) {
249
+ this.shouldBeConnected = true;
250
+ this.desiredJoinSignature = signature;
251
+ await this.ensureJoined(`end_users:${userId}`);
252
+ }
253
+ /** Upserts a group and the current user's membership on the joined channel. */
254
+ async group(payload) {
255
+ if (!this.channelTopic || !this.channelJoined) {
256
+ return Promise.reject(new UserflowAuthError('Not joined to an end-user channel'));
257
+ }
258
+ await this.enqueuePush('client_message', {
259
+ kind: 'UpsertGroup',
260
+ groupExternalId: payload.groupExternalId,
261
+ groupSignature: payload.groupSignature,
262
+ groupAttributes: payload.groupAttributes,
263
+ membershipAttributes: payload.membershipAttributes,
264
+ });
265
+ }
266
+ /** Intentional teardown: stop everything, reject queued/in-flight work, clear intent. */
267
+ disconnect() {
268
+ this.shouldBeConnected = false;
269
+ this.desiredTopic = null;
270
+ this.desiredJoinSignature = null;
271
+ this.activeFlowSessionId = null;
272
+ this.clientContext = { ...DEFAULT_CLIENT_CONTEXT };
273
+ this.clientConditions = [];
274
+ this.warnedUnhandledKinds.clear();
275
+ this.state = 'disconnected';
276
+ this.cancelReconnect();
277
+ this.stopHeartbeat();
278
+ this.abortPendingConnect(new UserflowAuthError('Connection closed'));
279
+ this.discardInFlightPushes();
280
+ this.rejectPending(new UserflowAuthError('Connection closed'));
281
+ this.rejectQueue(new UserflowAuthError('Connection closed'));
282
+ this.detachAndClose(this.ws);
283
+ this.resetChannel();
284
+ }
285
+ /** Background handling: close but keep intent + queued pushes so `resume()` recovers. */
286
+ suspend() {
287
+ if (this.state === 'suspended')
288
+ return;
289
+ this.state = 'suspended';
290
+ this.cancelReconnect();
291
+ this.stopHeartbeat();
292
+ this.abortPendingConnect(new UserflowAuthError('Connection suspended'));
293
+ this.discardInFlightPushes();
294
+ this.rejectPending(new UserflowAuthError('Connection suspended'));
295
+ this.detachAndClose(this.ws);
296
+ this.resetChannel();
297
+ }
298
+ /** Foreground handling: reconnect, rejoin the desired topic, and flush the queue. */
299
+ resume() {
300
+ if (this.state !== 'suspended')
301
+ return;
302
+ if (!this.shouldBeConnected) {
303
+ this.state = 'disconnected';
304
+ return;
305
+ }
306
+ this.state = 'reconnecting';
307
+ this.attemptEstablish();
308
+ }
309
+ /**
310
+ * Eager reconnect when the host reports connectivity restored.
311
+ * No-op while suspended (lifecycle owns that path), while already connecting /
312
+ * connected, or when the SDK does not intend to be connected.
313
+ */
314
+ reconnectNow() {
315
+ if (!this.shouldBeConnected)
316
+ return;
317
+ if (this.state === 'suspended' || this.state === 'connecting' || this.state === 'connected') {
318
+ return;
319
+ }
320
+ this.cancelReconnect();
321
+ this.backoff.reset();
322
+ this.state = 'reconnecting';
323
+ this.attemptEstablish();
324
+ }
325
+ /**
326
+ * Verify the connection is really alive right now and recover fast if not.
327
+ * Called on foreground and other "the socket may have silently died" moments.
328
+ *
329
+ * - connected: fire an immediate heartbeat probe with a short reply deadline;
330
+ * a dead socket is detected within {@link heartbeatReplyTimeoutMs} instead of
331
+ * waiting for the next scheduled heartbeat.
332
+ * - reconnecting / disconnected (with intent): reconnect eagerly.
333
+ * - suspended / connecting: no-op (those paths already own recovery).
334
+ *
335
+ * This is the zero-dependency liveness path; it does not require any
336
+ * host-provided network signal.
337
+ */
338
+ ensureAlive() {
339
+ if (!this.shouldBeConnected)
340
+ return;
341
+ if (this.state === 'connected' && this.ws) {
342
+ // A beat is already outstanding — its reply deadline will catch a death.
343
+ if (this.pendingHeartbeatRef !== null)
344
+ return;
345
+ this.clearHeartbeatTimer();
346
+ this.sendHeartbeat();
347
+ return;
348
+ }
349
+ this.reconnectNow();
350
+ }
351
+ async ensureJoined(topic) {
352
+ this.desiredTopic = topic;
353
+ if (this.state === 'connected' && this.channelTopic === topic && this.channelJoined) {
354
+ return;
355
+ }
356
+ if (this.state === 'connected' && this.channelTopic !== topic && this.ws) {
357
+ this.detachAndClose(this.ws);
358
+ this.resetChannel();
359
+ }
360
+ this.cancelReconnect();
361
+ await this.establish();
362
+ }
363
+ establish() {
364
+ if (this.establishPromise)
365
+ return this.establishPromise;
366
+ this.establishPromise = this.doEstablish().finally(() => {
367
+ this.establishPromise = null;
368
+ });
369
+ return this.establishPromise;
370
+ }
371
+ async doEstablish() {
372
+ this.state = 'connecting';
373
+ await this.openSocket();
374
+ if (this.desiredTopic) {
375
+ await this.joinChannel(this.desiredTopic);
376
+ }
377
+ this.state = 'connected';
378
+ this.backoff.reset();
379
+ this.startHeartbeat();
380
+ this.flush();
381
+ }
382
+ attemptEstablish() {
383
+ void this.establish().catch(() => {
384
+ if (this.shouldBeConnected && this.state !== 'suspended') {
385
+ this.scheduleReconnect();
386
+ }
387
+ });
388
+ }
389
+ openSocket() {
390
+ return new Promise((resolve, reject) => {
391
+ const SocketCtor = resolveSocketCtor(this.socketCtor);
392
+ const ws = new SocketCtor(buildWsUrl(this.serverEndpoint, this.clientToken));
393
+ this.ws = ws;
394
+ let settled = false;
395
+ const timer = this.scheduler.setTimeout(() => {
396
+ if (settled)
397
+ return;
398
+ settled = true;
399
+ this.pendingConnect = null;
400
+ this.detachAndClose(ws);
401
+ reject(new UserflowAuthError('WebSocket connection timed out'));
402
+ }, TIMEOUT_MS);
403
+ const finish = () => {
404
+ settled = true;
405
+ this.pendingConnect = null;
406
+ this.scheduler.clearTimeout(timer);
407
+ };
408
+ this.pendingConnect = {
409
+ reject: (error) => {
410
+ if (settled)
411
+ return;
412
+ finish();
413
+ this.detachAndClose(ws);
414
+ reject(error);
415
+ },
416
+ };
417
+ ws.onopen = () => {
418
+ if (settled)
419
+ return;
420
+ finish();
421
+ resolve();
422
+ };
423
+ ws.onmessage = (event) => this.handleMessage(event);
424
+ ws.onerror = () => {
425
+ if (!settled) {
426
+ finish();
427
+ this.detachAndClose(ws);
428
+ reject(new UserflowAuthError('WebSocket connection failed'));
429
+ }
430
+ else {
431
+ this.onSocketDown(ws);
432
+ }
433
+ };
434
+ ws.onclose = () => {
435
+ if (!settled) {
436
+ finish();
437
+ this.detachAndClose(ws);
438
+ reject(new UserflowAuthError('WebSocket closed before connect completed'));
439
+ }
440
+ else {
441
+ this.onSocketDown(ws);
442
+ }
443
+ };
444
+ });
445
+ }
446
+ onSocketDown(socket) {
447
+ if (this.ws !== socket)
448
+ return;
449
+ this.detachAndClose(socket);
450
+ this.channelJoined = false;
451
+ this.channelJoinRef = null;
452
+ this.stopHeartbeat();
453
+ this.discardInFlightPushes();
454
+ this.rejectPending(new UserflowAuthError('Connection dropped'));
455
+ if (this.shouldBeConnected && this.state !== 'suspended') {
456
+ this.state = 'reconnecting';
457
+ this.scheduleReconnect();
458
+ }
459
+ else {
460
+ this.state = 'disconnected';
461
+ }
462
+ }
463
+ scheduleReconnect() {
464
+ if (this.reconnectHandle !== null)
465
+ return;
466
+ const delay = this.backoff.nextDelay();
467
+ this.reconnectHandle = this.scheduler.setTimeout(() => {
468
+ this.reconnectHandle = null;
469
+ if (!this.shouldBeConnected || this.state === 'suspended')
470
+ return;
471
+ this.attemptEstablish();
472
+ }, delay);
473
+ }
474
+ cancelReconnect() {
475
+ if (this.reconnectHandle !== null) {
476
+ this.scheduler.clearTimeout(this.reconnectHandle);
477
+ this.reconnectHandle = null;
478
+ }
479
+ }
480
+ startHeartbeat() {
481
+ this.stopHeartbeat();
482
+ this.scheduleNextHeartbeat();
483
+ }
484
+ stopHeartbeat() {
485
+ this.clearHeartbeatTimer();
486
+ this.clearHeartbeatReplyTimer();
487
+ this.pendingHeartbeatRef = null;
488
+ }
489
+ scheduleNextHeartbeat() {
490
+ this.clearHeartbeatTimer();
491
+ this.heartbeatHandle = this.scheduler.setTimeout(() => this.sendHeartbeat(), this.heartbeatIntervalMs);
492
+ }
493
+ clearHeartbeatTimer() {
494
+ if (this.heartbeatHandle !== null) {
495
+ this.scheduler.clearTimeout(this.heartbeatHandle);
496
+ this.heartbeatHandle = null;
497
+ }
498
+ }
499
+ clearHeartbeatReplyTimer() {
500
+ if (this.heartbeatReplyHandle !== null) {
501
+ this.scheduler.clearTimeout(this.heartbeatReplyHandle);
502
+ this.heartbeatReplyHandle = null;
503
+ }
504
+ }
505
+ /**
506
+ * Send one heartbeat and arm a short reply deadline. The next heartbeat is
507
+ * scheduled when the reply lands (see {@link handleMessage}); if the deadline
508
+ * fires first with the beat still pending, the socket is declared dead.
509
+ */
510
+ sendHeartbeat() {
511
+ this.heartbeatHandle = null;
512
+ if (this.state !== 'connected' || !this.ws)
513
+ return;
514
+ // A prior beat was never answered (defensive; the reply timer normally
515
+ // catches this first).
516
+ if (this.pendingHeartbeatRef !== null) {
517
+ this.onSocketDown(this.ws);
518
+ return;
519
+ }
520
+ const ref = this.nextRef();
521
+ this.pendingHeartbeatRef = ref;
522
+ this.ws.send(JSON.stringify([null, ref, PHOENIX_TOPIC, 'heartbeat', {}]));
523
+ this.armHeartbeatReplyTimeout();
524
+ }
525
+ armHeartbeatReplyTimeout() {
526
+ this.clearHeartbeatReplyTimer();
527
+ this.heartbeatReplyHandle = this.scheduler.setTimeout(() => {
528
+ this.heartbeatReplyHandle = null;
529
+ if (this.state !== 'connected' || !this.ws)
530
+ return;
531
+ if (this.pendingHeartbeatRef !== null) {
532
+ this.onSocketDown(this.ws);
533
+ }
534
+ }, this.heartbeatReplyTimeoutMs);
535
+ }
536
+ handleMessage(event) {
537
+ let frame;
538
+ try {
539
+ frame = JSON.parse(String(event.data));
540
+ }
541
+ catch {
542
+ return;
543
+ }
544
+ if (!Array.isArray(frame) || frame.length < 5)
545
+ return;
546
+ const [, id, topic, eventName, payload] = frame;
547
+ if (eventName === 'server_message') {
548
+ this.dispatchServerMessage(topic, payload);
549
+ return;
550
+ }
551
+ if (eventName !== 'phx_reply' || typeof id !== 'string')
552
+ return;
553
+ if (topic === PHOENIX_TOPIC && id === this.pendingHeartbeatRef) {
554
+ this.pendingHeartbeatRef = null;
555
+ this.clearHeartbeatReplyTimer();
556
+ if (this.state === 'connected')
557
+ this.scheduleNextHeartbeat();
558
+ return;
559
+ }
560
+ const wait = this.pending.get(id);
561
+ if (!wait || wait.topic !== topic)
562
+ return;
563
+ this.pending.delete(id);
564
+ this.scheduler.clearTimeout(wait.timer);
565
+ const reply = payload;
566
+ if (reply.status === 'ok') {
567
+ wait.ok();
568
+ return;
569
+ }
570
+ wait.fail(new UserflowAuthError(`${topic}: ${joinError(reply.response)}`));
571
+ }
572
+ dispatchServerMessage(topic, payload) {
573
+ if (!this.channelTopic || topic !== this.channelTopic)
574
+ return;
575
+ if (!isServerMessage(payload)) {
576
+ if (typeof payload === 'object' &&
577
+ payload !== null &&
578
+ 'kind' in payload &&
579
+ typeof payload.kind === 'string') {
580
+ const kind = payload.kind;
581
+ if (!this.warnedUnhandledKinds.has(kind)) {
582
+ this.warnedUnhandledKinds.add(kind);
583
+ this.warn(`Userflow: ignoring unsupported server message kind "${kind}" ` +
584
+ '(not implemented by the mobile SDK)');
585
+ }
586
+ }
587
+ return;
588
+ }
589
+ for (const handler of this.serverMessageHandlers) {
590
+ try {
591
+ handler(payload);
592
+ }
593
+ catch {
594
+ // Never let a subscriber break the socket message loop.
595
+ }
596
+ }
597
+ }
598
+ nextRef() {
599
+ this.ref += 1;
600
+ return String(this.ref);
601
+ }
602
+ async joinChannel(topic) {
603
+ this.channelTopic = topic;
604
+ await this.join(topic, endUserJoinPayload(this.desiredJoinSignature, this.activeFlowSessionId, this.clientContext, this.clientConditions));
605
+ this.channelJoined = true;
606
+ }
607
+ join(topic, payload) {
608
+ return this.request(topic, (joinRef, ref) => {
609
+ if (topic.startsWith('end_users:')) {
610
+ this.channelJoinRef = joinRef;
611
+ }
612
+ return [joinRef, ref, topic, 'phx_join', payload];
613
+ });
614
+ }
615
+ request(topic, buildFrame) {
616
+ return new Promise((ok, fail) => {
617
+ if (!this.ws || this.ws.readyState !== WS_OPEN) {
618
+ fail(new UserflowAuthError('WebSocket is not open'));
619
+ return;
620
+ }
621
+ const joinRef = topic.startsWith('end_users:')
622
+ ? (this.channelJoinRef ?? this.nextRef())
623
+ : this.nextRef();
624
+ const ref = this.nextRef();
625
+ const timer = this.scheduler.setTimeout(() => {
626
+ const wait = this.pending.get(ref);
627
+ if (!wait)
628
+ return;
629
+ this.pending.delete(ref);
630
+ wait.fail(new UserflowAuthError(`${topic}: reply timed out`));
631
+ }, TIMEOUT_MS);
632
+ this.pending.set(ref, { topic, timer, ok, fail });
633
+ this.ws.send(JSON.stringify(buildFrame(joinRef, ref)));
634
+ });
635
+ }
636
+ /**
637
+ * Enqueue an outbound push. Only `client_message` events pass through here.
638
+ * Channel joins (`phx_join`) and heartbeats bypass this gate. Meta messages
639
+ * such as `ToggleClientCondition` may set `skipRateLimit` so condition
640
+ * updates are not dropped under UpsertUser / GoToStep load.
641
+ */
642
+ enqueuePush(event, payload, options) {
643
+ if (!options?.skipRateLimit && !this.rateLimiter.tryAcquire()) {
644
+ return Promise.reject(new UserflowRateLimitError(`Client message rate limit exceeded (${this.rateLimitCapacity}/min)`));
645
+ }
646
+ return new Promise((resolve, reject) => {
647
+ this.outbound.push({ event, payload, inFlight: false, inFlightRef: null, resolve, reject });
648
+ this.flush();
649
+ });
650
+ }
651
+ flush() {
652
+ if (this.state !== 'connected' || !this.channelJoined || !this.ws)
653
+ return;
654
+ for (const item of this.outbound) {
655
+ if (item.inFlight)
656
+ continue;
657
+ this.sendPush(item);
658
+ }
659
+ }
660
+ sendPush(item) {
661
+ if (!this.ws || !this.channelTopic || !this.channelJoinRef)
662
+ return;
663
+ const topic = this.channelTopic;
664
+ const joinRef = this.channelJoinRef;
665
+ const ref = this.nextRef();
666
+ const timer = this.scheduler.setTimeout(() => {
667
+ const wait = this.pending.get(ref);
668
+ if (!wait)
669
+ return;
670
+ this.pending.delete(ref);
671
+ wait.fail(new UserflowAuthError(`${topic}: reply timed out`));
672
+ }, TIMEOUT_MS);
673
+ this.pending.set(ref, {
674
+ topic,
675
+ timer,
676
+ ok: () => {
677
+ this.removeFromQueue(item);
678
+ item.resolve();
679
+ },
680
+ fail: (error) => {
681
+ this.removeFromQueue(item);
682
+ item.reject(error);
683
+ },
684
+ });
685
+ item.inFlight = true;
686
+ item.inFlightRef = ref;
687
+ this.ws.send(JSON.stringify([joinRef, ref, topic, item.event, item.payload]));
688
+ }
689
+ removeFromQueue(item) {
690
+ const index = this.outbound.indexOf(item);
691
+ if (index !== -1)
692
+ this.outbound.splice(index, 1);
693
+ }
694
+ /**
695
+ * On a drop/suspend, forget the in-flight reply for each queued push (without
696
+ * rejecting it) and reset it so it is re-sent after the next (re)join. This is
697
+ * the at-least-once behavior; safe because the pushes are idempotent.
698
+ */
699
+ discardInFlightPushes() {
700
+ for (const item of this.outbound) {
701
+ if (item.inFlight && item.inFlightRef) {
702
+ const wait = this.pending.get(item.inFlightRef);
703
+ if (wait) {
704
+ this.scheduler.clearTimeout(wait.timer);
705
+ this.pending.delete(item.inFlightRef);
706
+ }
707
+ }
708
+ item.inFlight = false;
709
+ item.inFlightRef = null;
710
+ }
711
+ }
712
+ rejectPending(error) {
713
+ for (const wait of this.pending.values()) {
714
+ this.scheduler.clearTimeout(wait.timer);
715
+ wait.fail(error);
716
+ }
717
+ this.pending.clear();
718
+ this.pendingHeartbeatRef = null;
719
+ }
720
+ rejectQueue(error) {
721
+ const items = this.outbound;
722
+ this.outbound = [];
723
+ for (const item of items)
724
+ item.reject(error);
725
+ }
726
+ abortPendingConnect(error) {
727
+ const pending = this.pendingConnect;
728
+ this.pendingConnect = null;
729
+ pending?.reject(error);
730
+ }
731
+ detachAndClose(ws) {
732
+ if (!ws)
733
+ return;
734
+ ws.onopen = null;
735
+ ws.onmessage = null;
736
+ ws.onerror = null;
737
+ ws.onclose = null;
738
+ try {
739
+ ws.close();
740
+ }
741
+ catch {
742
+ // ignore
743
+ }
744
+ if (this.ws === ws)
745
+ this.ws = null;
746
+ }
747
+ resetChannel() {
748
+ this.channelJoinRef = null;
749
+ this.channelTopic = null;
750
+ this.channelJoined = false;
751
+ }
752
+ }
753
+ /** Convenience helper: open a connection, validate credentials, and close it. */
754
+ export function validateCredentials(clientToken, serverEndpoint, socketCtor) {
755
+ return new PhoenixConnection(clientToken, serverEndpoint, socketCtor).validate();
756
+ }
757
+ //# sourceMappingURL=phoenix.js.map