@superleapai/flow-ui 1.0.2 → 2.2.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/core/bridge.js ADDED
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Superleap-Flow Bridge Transport Layer
3
+ *
4
+ * Handles postMessage / MessageChannel communication between an iframe
5
+ * (running this library) and the host CRM window. Also supports React
6
+ * Native WebView via window.ReactNativeWebView.
7
+ *
8
+ * This module is transport-only — it knows nothing about CRM concepts,
9
+ * the SuperLeap SDK, or FlowUI state. The higher-level core/crm.js
10
+ * builds on top of it.
11
+ *
12
+ * Exposed temporarily as window.SuperleapBridge, then captured into
13
+ * _components and removed from the global scope by index.js.
14
+ */
15
+
16
+ (function (global) {
17
+ "use strict";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Constants
21
+ // ---------------------------------------------------------------------------
22
+
23
+ var MESSAGE_TYPE = "superleap-bridge";
24
+ var HANDSHAKE_TIMEOUT = 5000; // ms
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Closure state (reset on each connect / disconnect cycle)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ var _instanceId = null;
31
+ var _nonce = null;
32
+ var _port = null; // MessagePort (iframe transport, post-handshake)
33
+ var _connected = false;
34
+ var _transport = null; // 'iframe' | 'react-native'
35
+ var _crmOrigin = null; // validated origin (iframe only)
36
+ var _messageListener = null; // window 'message' handler ref
37
+ var _eventHandlers = {}; // { action: [cb, …] }
38
+ var _pendingResolve = null;
39
+ var _pendingReject = null;
40
+ var _handshakeTimer = null;
41
+ var _useMessageChannel = true; // false when MessageChannel unavailable
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers – crypto
45
+ // ---------------------------------------------------------------------------
46
+
47
+ function generateUUID() {
48
+ // crypto.randomUUID where available (modern browsers)
49
+ if (
50
+ typeof crypto !== "undefined" &&
51
+ typeof crypto.randomUUID === "function"
52
+ ) {
53
+ return crypto.randomUUID();
54
+ }
55
+ // Fallback: manual v4 UUID via getRandomValues or Math.random
56
+ var bytes;
57
+ if (
58
+ typeof crypto !== "undefined" &&
59
+ typeof crypto.getRandomValues === "function"
60
+ ) {
61
+ bytes = new Uint8Array(16);
62
+ crypto.getRandomValues(bytes);
63
+ } else {
64
+ bytes = new Uint8Array(16);
65
+ for (var i = 0; i < 16; i++) {
66
+ bytes[i] = (Math.random() * 256) | 0;
67
+ }
68
+ }
69
+ bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
70
+ bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
71
+ var hex = [];
72
+ for (var j = 0; j < 16; j++) {
73
+ hex.push(("0" + bytes[j].toString(16)).slice(-2));
74
+ }
75
+ return (
76
+ hex.slice(0, 4).join("") +
77
+ "-" +
78
+ hex.slice(4, 6).join("") +
79
+ "-" +
80
+ hex.slice(6, 8).join("") +
81
+ "-" +
82
+ hex.slice(8, 10).join("") +
83
+ "-" +
84
+ hex.slice(10).join("")
85
+ );
86
+ }
87
+
88
+ function generateNonce() {
89
+ var bytes;
90
+ if (
91
+ typeof crypto !== "undefined" &&
92
+ typeof crypto.getRandomValues === "function"
93
+ ) {
94
+ bytes = new Uint8Array(16);
95
+ crypto.getRandomValues(bytes);
96
+ } else {
97
+ bytes = new Uint8Array(16);
98
+ for (var i = 0; i < 16; i++) {
99
+ bytes[i] = (Math.random() * 256) | 0;
100
+ }
101
+ }
102
+ var hex = "";
103
+ for (var j = 0; j < bytes.length; j++) {
104
+ hex += ("0" + bytes[j].toString(16)).slice(-2);
105
+ }
106
+ return hex;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Helpers – transport
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Detect which transport to use.
115
+ * @returns {'react-native'|'iframe'}
116
+ */
117
+ function detectTransport() {
118
+ if (
119
+ global.ReactNativeWebView &&
120
+ typeof global.ReactNativeWebView.postMessage === "function"
121
+ ) {
122
+ return "react-native";
123
+ }
124
+ if (global.parent && global.parent !== global) {
125
+ return "iframe";
126
+ }
127
+ throw new Error(
128
+ "SuperleapBridge: Not running in an embedded context (no parent window or ReactNativeWebView)",
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Wrap action + payload in the standard message envelope.
134
+ */
135
+ function createEnvelope(action, payload) {
136
+ return {
137
+ type: MESSAGE_TYPE,
138
+ instanceId: _instanceId,
139
+ action: action,
140
+ payload: payload || {},
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Low-level send. Before handshake-complete the message goes via
146
+ * window.parent.postMessage (iframe) or ReactNativeWebView.postMessage (RN).
147
+ * After handshake-complete it goes via the dedicated MessagePort (iframe).
148
+ * RN always uses ReactNativeWebView.postMessage (no MessageChannel).
149
+ *
150
+ * @param {Object} message – envelope object
151
+ * @param {Array} [transfer] – Transferable objects (e.g. [port2])
152
+ */
153
+ function sendRaw(message, transfer) {
154
+ if (_transport === "react-native") {
155
+ global.ReactNativeWebView.postMessage(JSON.stringify(message));
156
+ return;
157
+ }
158
+
159
+ // iframe transport
160
+ if (_port) {
161
+ // Post-handshake — private channel
162
+ _port.postMessage(message);
163
+ } else {
164
+ // Pre-handshake or no MessageChannel — broadcast via parent
165
+ var targetOrigin = _crmOrigin || "*";
166
+ if (transfer && transfer.length) {
167
+ global.parent.postMessage(message, targetOrigin, transfer);
168
+ } else {
169
+ global.parent.postMessage(message, targetOrigin);
170
+ }
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Incoming message handling
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Central dispatcher for all incoming messages (both during handshake and
180
+ * after).
181
+ *
182
+ * @param {Object} data – parsed message data
183
+ * @param {string} origin – event.origin (empty string for RN / port msgs)
184
+ */
185
+ function handleIncomingMessage(data, origin) {
186
+ // Ignore messages that aren't ours
187
+ if (!data || data.type !== MESSAGE_TYPE) return;
188
+ // Ignore messages for a different instance
189
+ if (data.instanceId && data.instanceId !== _instanceId) return;
190
+
191
+ var action = data.action;
192
+
193
+ // ----- Handshake responses -----
194
+ if (action === "handshake-ack" && _pendingResolve) {
195
+ // Validate nonce
196
+ if (!data.payload || data.payload.nonce !== _nonce) {
197
+ if (_pendingReject) {
198
+ _pendingReject(
199
+ new Error(
200
+ "SuperleapBridge: Nonce validation failed (possible replay)",
201
+ ),
202
+ );
203
+ }
204
+ cleanup();
205
+ return;
206
+ }
207
+
208
+ // Validate origin (iframe only)
209
+ if (_transport === "iframe" && _crmOrigin && origin !== _crmOrigin) {
210
+ if (_pendingReject) {
211
+ _pendingReject(
212
+ new Error("SuperleapBridge: Origin validation failed"),
213
+ );
214
+ }
215
+ cleanup();
216
+ return;
217
+ }
218
+
219
+ // Store validated origin
220
+ if (_transport === "iframe" && origin) {
221
+ _crmOrigin = origin;
222
+ }
223
+
224
+ clearTimeout(_handshakeTimer);
225
+ _handshakeTimer = null;
226
+
227
+ // Attempt MessageChannel upgrade (iframe only)
228
+ if (
229
+ _transport === "iframe" &&
230
+ _useMessageChannel &&
231
+ typeof MessageChannel !== "undefined"
232
+ ) {
233
+ var channel = new MessageChannel();
234
+ _port = channel.port1;
235
+ _port.onmessage = function (evt) {
236
+ handleIncomingMessage(evt.data, "");
237
+ };
238
+
239
+ // Send handshake-complete with port2 transferred
240
+ var completeMsg = createEnvelope("handshake-complete", {});
241
+ global.parent.postMessage(completeMsg, _crmOrigin, [channel.port2]);
242
+ } else {
243
+ // No channel upgrade — send plain handshake-complete
244
+ sendRaw(createEnvelope("handshake-complete", {}));
245
+ }
246
+
247
+ _connected = true;
248
+ _nonce = null; // one-time use
249
+
250
+ // Remove the broad window listener if we have a port now
251
+ if (_port && _messageListener) {
252
+ global.removeEventListener("message", _messageListener);
253
+ _messageListener = null;
254
+ }
255
+
256
+ var resolve = _pendingResolve;
257
+ _pendingResolve = null;
258
+ _pendingReject = null;
259
+ resolve(data.payload);
260
+ return;
261
+ }
262
+
263
+ // ----- Post-handshake messages -----
264
+ if (_connected) {
265
+ // Origin check for non-port iframe messages
266
+ if (
267
+ _transport === "iframe" &&
268
+ !_port &&
269
+ _crmOrigin &&
270
+ origin &&
271
+ origin !== _crmOrigin
272
+ ) {
273
+ return; // ignore messages from unexpected origins
274
+ }
275
+
276
+ emitEvent(action, data.payload);
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Fire callbacks registered for a given action.
282
+ */
283
+ function emitEvent(action, payload) {
284
+ var handlers = _eventHandlers[action];
285
+ if (!handlers || !handlers.length) return;
286
+ // Iterate over a copy in case a handler removes itself
287
+ var copy = handlers.slice();
288
+ for (var i = 0; i < copy.length; i++) {
289
+ try {
290
+ copy[i](payload);
291
+ } catch (e) {
292
+ console.error("SuperleapBridge: handler error for " + action, e);
293
+ }
294
+ }
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Lifecycle helpers
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Reset all state to initial values.
303
+ */
304
+ function cleanup() {
305
+ if (_handshakeTimer) {
306
+ clearTimeout(_handshakeTimer);
307
+ _handshakeTimer = null;
308
+ }
309
+ if (_port) {
310
+ try {
311
+ _port.close();
312
+ } catch (_) {}
313
+ _port = null;
314
+ }
315
+ if (_messageListener) {
316
+ global.removeEventListener("message", _messageListener);
317
+ _messageListener = null;
318
+ }
319
+ if (_transport === "react-native" && global.__superleapBridge) {
320
+ delete global.__superleapBridge;
321
+ }
322
+ _instanceId = null;
323
+ _nonce = null;
324
+ _connected = false;
325
+ _transport = null;
326
+ _crmOrigin = null;
327
+ _pendingResolve = null;
328
+ _pendingReject = null;
329
+ // Event handlers are intentionally preserved across reconnects
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Public API
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /**
337
+ * Initiate a handshake with the host CRM.
338
+ *
339
+ * @param {Object} options
340
+ * @param {string} options.flowId – identifies this flow to the CRM
341
+ * @param {string} [options.version] – library version (informational)
342
+ * @param {string} [options.crmOrigin] – expected CRM origin for validation
343
+ * @param {number} [options.timeout] – handshake timeout in ms (default 5000)
344
+ * @returns {Promise<Object>} resolves with the CRM's handshake-ack payload
345
+ */
346
+ function connect(options) {
347
+ if (_connected) {
348
+ return Promise.reject(
349
+ new Error(
350
+ "SuperleapBridge: Already connected. Call disconnect() first.",
351
+ ),
352
+ );
353
+ }
354
+
355
+ var opts = options || {};
356
+
357
+ return new Promise(function (resolve, reject) {
358
+ try {
359
+ _transport = detectTransport();
360
+ } catch (err) {
361
+ reject(err);
362
+ return;
363
+ }
364
+
365
+ _instanceId = generateUUID();
366
+ _nonce = generateNonce();
367
+ _crmOrigin = opts.crmOrigin || null;
368
+ _pendingResolve = resolve;
369
+ _pendingReject = reject;
370
+ _useMessageChannel = typeof MessageChannel !== "undefined";
371
+
372
+ // --- Set up listeners ---
373
+ if (_transport === "iframe") {
374
+ _messageListener = function (event) {
375
+ handleIncomingMessage(event.data, event.origin);
376
+ };
377
+ global.addEventListener("message", _messageListener);
378
+ } else {
379
+ // React Native — register global receiver
380
+ global.__superleapBridge = {
381
+ receive: function (raw) {
382
+ var data;
383
+ if (typeof raw === "string") {
384
+ try {
385
+ data = JSON.parse(raw);
386
+ } catch (_) {
387
+ return;
388
+ }
389
+ } else {
390
+ data = raw;
391
+ }
392
+ handleIncomingMessage(data, "");
393
+ },
394
+ };
395
+ // Also listen on window.message for RN webViewRef.postMessage
396
+ _messageListener = function (event) {
397
+ var data = event.data;
398
+ if (typeof data === "string") {
399
+ try {
400
+ data = JSON.parse(data);
401
+ } catch (_) {
402
+ return;
403
+ }
404
+ }
405
+ handleIncomingMessage(data, "");
406
+ };
407
+ global.addEventListener("message", _messageListener);
408
+ }
409
+
410
+ // --- Send handshake-request ---
411
+ var envelope = createEnvelope("handshake-request", {
412
+ flowId: opts.flowId || "",
413
+ version: opts.version || "",
414
+ nonce: _nonce,
415
+ });
416
+ sendRaw(envelope);
417
+
418
+ // --- Timeout ---
419
+ var timeoutMs = opts.timeout || HANDSHAKE_TIMEOUT;
420
+ _handshakeTimer = setTimeout(function () {
421
+ if (!_connected) {
422
+ var err = new Error(
423
+ "SuperleapBridge: Handshake timed out after " +
424
+ timeoutMs +
425
+ "ms. Is this page embedded in the SuperLeap CRM?",
426
+ );
427
+ if (_pendingReject) _pendingReject(err);
428
+ cleanup();
429
+ }
430
+ }, timeoutMs);
431
+ });
432
+ }
433
+
434
+ /**
435
+ * Tear down the connection and clean up all listeners.
436
+ */
437
+ function disconnect() {
438
+ if (_connected) {
439
+ try {
440
+ sendRaw(createEnvelope("disconnect", {}));
441
+ } catch (_) {
442
+ // best-effort
443
+ }
444
+ }
445
+ cleanup();
446
+ }
447
+
448
+ /**
449
+ * Send a message to the CRM over the established connection.
450
+ *
451
+ * @param {string} action – message action / event name
452
+ * @param {Object} [payload]
453
+ */
454
+ function send(action, payload) {
455
+ if (!_connected) {
456
+ throw new Error(
457
+ "SuperleapBridge: Not connected. Call connect() first.",
458
+ );
459
+ }
460
+ sendRaw(createEnvelope(action, payload));
461
+ }
462
+
463
+ /**
464
+ * Register a handler for messages with a given action.
465
+ *
466
+ * @param {string} action
467
+ * @param {Function} callback – receives (payload)
468
+ * @returns {Function} unsubscribe function
469
+ */
470
+ function onMessage(action, callback) {
471
+ if (!_eventHandlers[action]) {
472
+ _eventHandlers[action] = [];
473
+ }
474
+ _eventHandlers[action].push(callback);
475
+
476
+ return function unsubscribe() {
477
+ offMessage(action, callback);
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Remove a specific handler for an action.
483
+ */
484
+ function offMessage(action, callback) {
485
+ var handlers = _eventHandlers[action];
486
+ if (!handlers) return;
487
+ var idx = handlers.indexOf(callback);
488
+ if (idx !== -1) {
489
+ handlers.splice(idx, 1);
490
+ }
491
+ }
492
+
493
+ /**
494
+ * @returns {boolean}
495
+ */
496
+ function isConnected() {
497
+ return _connected;
498
+ }
499
+
500
+ /**
501
+ * @returns {string|null}
502
+ */
503
+ function getInstanceId() {
504
+ return _instanceId;
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Expose
509
+ // ---------------------------------------------------------------------------
510
+
511
+ var SuperleapBridge = {
512
+ connect: connect,
513
+ disconnect: disconnect,
514
+ send: send,
515
+ onMessage: onMessage,
516
+ offMessage: offMessage,
517
+ isConnected: isConnected,
518
+ getInstanceId: getInstanceId,
519
+ };
520
+
521
+ if (global) {
522
+ global.SuperleapBridge = SuperleapBridge;
523
+ }
524
+ })(typeof window !== "undefined" ? window : this);