webview.io 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +496 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.js +511 -0
- package/package.json +69 -0
- package/src/index.ts +768 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
import type { RefObject } from 'react'
|
|
2
|
+
import type { WebView } from 'react-native-webview'
|
|
3
|
+
|
|
4
|
+
export type PeerType = 'WEBVIEW' | 'EMBEDDED'
|
|
5
|
+
|
|
6
|
+
export type AckFunction = ( error: boolean | string, ...args: any[] ) => void
|
|
7
|
+
export type Listener = ( payload?: any, ack?: AckFunction ) => void
|
|
8
|
+
|
|
9
|
+
export type Options = {
|
|
10
|
+
type?: PeerType
|
|
11
|
+
debug?: boolean
|
|
12
|
+
heartbeatInterval?: number
|
|
13
|
+
connectionTimeout?: number
|
|
14
|
+
maxMessageSize?: number
|
|
15
|
+
maxMessagesPerSecond?: number
|
|
16
|
+
autoReconnect?: boolean
|
|
17
|
+
messageQueueSize?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RegisteredEvents {
|
|
21
|
+
[index: string]: Listener[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Peer = {
|
|
25
|
+
type: PeerType
|
|
26
|
+
webViewRef?: RefObject<WebView>
|
|
27
|
+
origin?: string
|
|
28
|
+
connected?: boolean
|
|
29
|
+
lastHeartbeat?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type MessageData = {
|
|
33
|
+
_event: string
|
|
34
|
+
payload: any
|
|
35
|
+
cid: string | undefined
|
|
36
|
+
timestamp?: number
|
|
37
|
+
size?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Message = {
|
|
41
|
+
data: MessageData
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type QueuedMessage = {
|
|
45
|
+
_event: string
|
|
46
|
+
payload: any
|
|
47
|
+
fn?: AckFunction
|
|
48
|
+
timestamp: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function newObject( data: object ){
|
|
52
|
+
return JSON.parse( JSON.stringify( data ) )
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getMessageSize( data: any ): number {
|
|
56
|
+
try { return JSON.stringify( data ).length }
|
|
57
|
+
catch { return 0 }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sanitizePayload( payload: any, maxSize: number ): any {
|
|
61
|
+
if( !payload ) return payload
|
|
62
|
+
|
|
63
|
+
const size = getMessageSize( payload )
|
|
64
|
+
if( size > maxSize )
|
|
65
|
+
throw new Error(`Message size ${size} exceeds limit ${maxSize}`)
|
|
66
|
+
|
|
67
|
+
// Basic sanitization - remove functions and undefined values
|
|
68
|
+
return JSON.parse( JSON.stringify( payload ) )
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ackId = () => {
|
|
72
|
+
const
|
|
73
|
+
rmin = 100000,
|
|
74
|
+
rmax = 999999,
|
|
75
|
+
timestamp = Date.now(),
|
|
76
|
+
random = Math.floor( Math.random() * ( rmax - rmin + 1 ) + rmin )
|
|
77
|
+
|
|
78
|
+
return `${timestamp}_${random}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const RESERVED_EVENTS = [
|
|
82
|
+
'ping',
|
|
83
|
+
'pong',
|
|
84
|
+
'__heartbeat',
|
|
85
|
+
'__heartbeat_response'
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
export default class WIO {
|
|
89
|
+
Events: RegisteredEvents
|
|
90
|
+
peer: Peer
|
|
91
|
+
options: Options
|
|
92
|
+
private heartbeatTimer?: NodeJS.Timeout
|
|
93
|
+
private reconnectTimer?: NodeJS.Timeout
|
|
94
|
+
private messageQueue: QueuedMessage[] = []
|
|
95
|
+
private messageRateTracker: number[] = []
|
|
96
|
+
private reconnectAttempts: number = 0
|
|
97
|
+
private maxReconnectAttempts: number = 5
|
|
98
|
+
|
|
99
|
+
constructor( options: Options = {} ){
|
|
100
|
+
if( options && typeof options !== 'object' )
|
|
101
|
+
throw new Error('Invalid Options')
|
|
102
|
+
|
|
103
|
+
this.options = {
|
|
104
|
+
debug: false,
|
|
105
|
+
heartbeatInterval: 30000, // 30 seconds
|
|
106
|
+
connectionTimeout: 10000, // 10 seconds
|
|
107
|
+
maxMessageSize: 1024 * 1024, // 1MB
|
|
108
|
+
maxMessagesPerSecond: 100,
|
|
109
|
+
autoReconnect: true,
|
|
110
|
+
messageQueueSize: 50,
|
|
111
|
+
...options
|
|
112
|
+
}
|
|
113
|
+
this.Events = {}
|
|
114
|
+
this.peer = { type: 'WEBVIEW', connected: false }
|
|
115
|
+
|
|
116
|
+
if( options.type )
|
|
117
|
+
this.peer.type = options.type
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
debug( ...args: any[] ){
|
|
121
|
+
this.options.debug && console.debug( ...args )
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
isConnected(): boolean {
|
|
125
|
+
return !!this.peer.connected && !!this.peer.webViewRef
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Enhanced connection health monitoring
|
|
129
|
+
private startHeartbeat(){
|
|
130
|
+
if( !this.options.heartbeatInterval ) return
|
|
131
|
+
|
|
132
|
+
this.heartbeatTimer = setInterval(() => {
|
|
133
|
+
if( this.isConnected() ){
|
|
134
|
+
const now = Date.now()
|
|
135
|
+
|
|
136
|
+
// Check if peer is still responsive
|
|
137
|
+
if( this.peer.lastHeartbeat
|
|
138
|
+
&& ( now - this.peer.lastHeartbeat ) > ( this.options.heartbeatInterval! * 2 ) ){
|
|
139
|
+
this.debug(`[${this.peer.type}] Heartbeat timeout detected`)
|
|
140
|
+
this.handleConnectionLoss()
|
|
141
|
+
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Send heartbeat
|
|
146
|
+
try { this.emit('__heartbeat', { timestamp: now }) }
|
|
147
|
+
catch( error ){
|
|
148
|
+
this.debug(`[${this.peer.type}] Heartbeat send failed:`, error)
|
|
149
|
+
this.handleConnectionLoss()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}, this.options.heartbeatInterval )
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private stopHeartbeat(){
|
|
156
|
+
if( !this.heartbeatTimer ) return
|
|
157
|
+
|
|
158
|
+
clearInterval( this.heartbeatTimer )
|
|
159
|
+
this.heartbeatTimer = undefined
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Handle connection loss and potential reconnection
|
|
163
|
+
private handleConnectionLoss(){
|
|
164
|
+
if( !this.peer.connected ) return
|
|
165
|
+
|
|
166
|
+
this.peer.connected = false
|
|
167
|
+
this.stopHeartbeat()
|
|
168
|
+
this.fire('disconnect', { reason: 'CONNECTION_LOST' })
|
|
169
|
+
|
|
170
|
+
this.options.autoReconnect
|
|
171
|
+
&& this.reconnectAttempts < this.maxReconnectAttempts
|
|
172
|
+
&& this.attemptReconnection()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private attemptReconnection(){
|
|
176
|
+
if( this.reconnectTimer ) return
|
|
177
|
+
|
|
178
|
+
this.reconnectAttempts++
|
|
179
|
+
const delay = Math.min( 1000 * Math.pow( 2, this.reconnectAttempts - 1 ), 30000 ) // Exponential backoff, max 30s
|
|
180
|
+
|
|
181
|
+
this.debug(`[${this.peer.type}] Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`)
|
|
182
|
+
this.fire('reconnecting', { attempt: this.reconnectAttempts, delay })
|
|
183
|
+
|
|
184
|
+
this.reconnectTimer = setTimeout(() => {
|
|
185
|
+
this.reconnectTimer = undefined
|
|
186
|
+
|
|
187
|
+
// Re-initiate connection for WEBVIEW type
|
|
188
|
+
this.peer.type === 'WEBVIEW'
|
|
189
|
+
&& this.emit('ping')
|
|
190
|
+
|
|
191
|
+
// For EMBEDDED type, just wait for incoming connection
|
|
192
|
+
|
|
193
|
+
// Set timeout for this reconnection attempt
|
|
194
|
+
setTimeout(() => {
|
|
195
|
+
if( this.peer.connected ) return
|
|
196
|
+
|
|
197
|
+
this.reconnectAttempts < this.maxReconnectAttempts
|
|
198
|
+
? this.attemptReconnection()
|
|
199
|
+
: this.fire('reconnection_failed', { attempts: this.reconnectAttempts })
|
|
200
|
+
}, this.options.connectionTimeout!)
|
|
201
|
+
}, delay)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Message rate limiting
|
|
205
|
+
private checkRateLimit(): boolean {
|
|
206
|
+
if( !this.options.maxMessagesPerSecond ) return true
|
|
207
|
+
|
|
208
|
+
const
|
|
209
|
+
now = Date.now(),
|
|
210
|
+
aSecondAgo = now - 1000
|
|
211
|
+
|
|
212
|
+
// Clean old entries
|
|
213
|
+
this.messageRateTracker = this.messageRateTracker.filter( timestamp => timestamp > aSecondAgo )
|
|
214
|
+
|
|
215
|
+
// Check if limit exceeded
|
|
216
|
+
if( this.messageRateTracker.length >= this.options.maxMessagesPerSecond ){
|
|
217
|
+
this.fire('error', {
|
|
218
|
+
type: 'RATE_LIMIT_EXCEEDED',
|
|
219
|
+
limit: this.options.maxMessagesPerSecond,
|
|
220
|
+
current: this.messageRateTracker.length
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.messageRateTracker.push( now )
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Queue messages when not connected
|
|
231
|
+
private queueMessage( _event: string, payload?: any, fn?: AckFunction ){
|
|
232
|
+
if( this.messageQueue.length >= this.options.messageQueueSize! ){
|
|
233
|
+
// Remove oldest message
|
|
234
|
+
const removed = this.messageQueue.shift()
|
|
235
|
+
this.debug(`[${this.peer.type}] Message queue full, removed oldest message:`, removed?._event)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.messageQueue.push({
|
|
239
|
+
_event,
|
|
240
|
+
payload,
|
|
241
|
+
fn,
|
|
242
|
+
timestamp: Date.now()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
this.debug(`[${this.peer.type}] Queued message: ${_event} (queue size: ${this.messageQueue.length})`)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Process queued messages when connection is established
|
|
249
|
+
private processMessageQueue(){
|
|
250
|
+
if( !this.isConnected() || this.messageQueue.length === 0 ) return
|
|
251
|
+
|
|
252
|
+
this.debug(`[${this.peer.type}] Processing ${this.messageQueue.length} queued messages`)
|
|
253
|
+
|
|
254
|
+
const queue = [...this.messageQueue]
|
|
255
|
+
this.messageQueue = []
|
|
256
|
+
|
|
257
|
+
queue.forEach( message => {
|
|
258
|
+
try { this.emit( message._event, message.payload, message.fn ) }
|
|
259
|
+
catch( error ){ this.debug(`[${this.peer.type}] Failed to send queued message:`, error) }
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Establish a connection with WebView
|
|
265
|
+
*/
|
|
266
|
+
initiate( webViewRef: RefObject<WebView>, origin: string ){
|
|
267
|
+
if( !webViewRef || !origin )
|
|
268
|
+
throw new Error('Invalid Connection initiation arguments')
|
|
269
|
+
|
|
270
|
+
if( this.peer.type === 'EMBEDDED' )
|
|
271
|
+
throw new Error('Expect EMBEDDED to <listen> and WEBVIEW to <initiate> a connection')
|
|
272
|
+
|
|
273
|
+
// Clean up existing resources if any
|
|
274
|
+
this.cleanup()
|
|
275
|
+
|
|
276
|
+
this.peer.webViewRef = webViewRef
|
|
277
|
+
this.peer.origin = origin
|
|
278
|
+
this.peer.connected = false
|
|
279
|
+
this.reconnectAttempts = 0
|
|
280
|
+
|
|
281
|
+
this.debug(`[${this.peer.type}] Initiate connection: WebView origin <${origin}>`)
|
|
282
|
+
this.emit('ping')
|
|
283
|
+
|
|
284
|
+
return this
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Listening to connection from the WebView host
|
|
289
|
+
* Note: In React Native context, this is handled by injected JavaScript
|
|
290
|
+
*/
|
|
291
|
+
listen( hostOrigin?: string ){
|
|
292
|
+
this.peer.type = 'EMBEDDED' // iframe.io-rn connection listener is automatically set as EMBEDDED
|
|
293
|
+
this.peer.connected = false
|
|
294
|
+
this.reconnectAttempts = 0
|
|
295
|
+
|
|
296
|
+
this.debug(`[${this.peer.type}] Listening to connect${hostOrigin ? `: Host <${hostOrigin}>` : ''}`)
|
|
297
|
+
|
|
298
|
+
return this
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Handle incoming message from WebView
|
|
303
|
+
* Called by React Native component via onMessage prop
|
|
304
|
+
*/
|
|
305
|
+
handleMessage( event: { nativeEvent: { data: string } } ){
|
|
306
|
+
try {
|
|
307
|
+
const data = JSON.parse( event.nativeEvent.data )
|
|
308
|
+
|
|
309
|
+
// Enhanced security: check valid message structure
|
|
310
|
+
if( typeof data !== 'object' || !data.hasOwnProperty('_event') ) return
|
|
311
|
+
|
|
312
|
+
const { _event, payload, cid, timestamp } = data as Message['data']
|
|
313
|
+
|
|
314
|
+
// Handle heartbeat responses
|
|
315
|
+
if( _event === '__heartbeat_response' ){
|
|
316
|
+
this.peer.lastHeartbeat = Date.now()
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Handle heartbeat requests
|
|
321
|
+
if( _event === '__heartbeat' ){
|
|
322
|
+
this.emit('__heartbeat_response', { timestamp: Date.now() })
|
|
323
|
+
this.peer.lastHeartbeat = Date.now()
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
this.debug(`[${this.peer.type}] Message: ${_event}`, payload || '')
|
|
328
|
+
|
|
329
|
+
// Handshake or availability check events
|
|
330
|
+
if( _event == 'pong' ){
|
|
331
|
+
// WebView is connected
|
|
332
|
+
this.peer.connected = true
|
|
333
|
+
this.reconnectAttempts = 0
|
|
334
|
+
this.peer.lastHeartbeat = Date.now()
|
|
335
|
+
|
|
336
|
+
this.startHeartbeat()
|
|
337
|
+
this.fire('connect')
|
|
338
|
+
this.processMessageQueue()
|
|
339
|
+
this.debug(`[${this.peer.type}] connected`)
|
|
340
|
+
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Fire available event listeners
|
|
345
|
+
this.fire( _event, payload, cid )
|
|
346
|
+
}
|
|
347
|
+
catch( error ){
|
|
348
|
+
this.debug(`[${this.peer.type}] Message handling error:`, error)
|
|
349
|
+
this.fire('error', {
|
|
350
|
+
type: 'MESSAGE_HANDLING_ERROR',
|
|
351
|
+
error: error instanceof Error ? error.message : String(error)
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fire( _event: string, payload?: MessageData['payload'], cid?: string ){
|
|
357
|
+
// Volatile event - check if any listeners exist
|
|
358
|
+
if( !this.Events[_event] && !this.Events[_event + '--@once'] ){
|
|
359
|
+
this.debug(`[${this.peer.type}] No <${_event}> listener defined`)
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const ackFn = cid
|
|
364
|
+
? ( error: boolean | string, ...args: any[] ): void => {
|
|
365
|
+
this.emit(`${_event}--${cid}--@ack`, { error: error || false, args })
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
: undefined
|
|
369
|
+
let listeners: Listener[] = []
|
|
370
|
+
|
|
371
|
+
if( this.Events[_event + '--@once'] ){
|
|
372
|
+
// Once triggable event
|
|
373
|
+
_event += '--@once'
|
|
374
|
+
listeners = this.Events[_event]
|
|
375
|
+
// Delete once event listeners after fired
|
|
376
|
+
delete this.Events[_event]
|
|
377
|
+
}
|
|
378
|
+
else listeners = this.Events[_event]
|
|
379
|
+
|
|
380
|
+
// Fire listeners with error handling
|
|
381
|
+
listeners.forEach( fn => {
|
|
382
|
+
try { payload !== undefined ? fn( payload, ackFn ) : fn( ackFn ) }
|
|
383
|
+
catch( error ){
|
|
384
|
+
this.debug(`[${this.peer.type}] Listener error for ${_event}:`, error)
|
|
385
|
+
this.fire('error', {
|
|
386
|
+
type: 'LISTENER_ERROR',
|
|
387
|
+
event: _event,
|
|
388
|
+
error: error instanceof Error ? error.message : String(error)
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
emit<T = any>( _event: string, payload?: T | AckFunction, fn?: AckFunction ){
|
|
395
|
+
// Check rate limiting
|
|
396
|
+
if( !this.checkRateLimit() ) return this
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Queue message if not connected: Except for
|
|
400
|
+
* connection-related events
|
|
401
|
+
*/
|
|
402
|
+
if( !this.isConnected() && !RESERVED_EVENTS.includes(_event) ){
|
|
403
|
+
this.queueMessage( _event, payload, fn )
|
|
404
|
+
return this
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if( !this.peer.webViewRef ){
|
|
408
|
+
this.fire('error', { type: 'NO_CONNECTION', event: _event })
|
|
409
|
+
return this
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if( typeof payload == 'function' ){
|
|
413
|
+
fn = payload as AckFunction
|
|
414
|
+
payload = undefined
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
// Enhanced security: sanitize and validate payload
|
|
419
|
+
const sanitizedPayload = payload
|
|
420
|
+
? sanitizePayload( payload, this.options.maxMessageSize! )
|
|
421
|
+
: payload
|
|
422
|
+
|
|
423
|
+
// Acknowledge event listener
|
|
424
|
+
let cid: string | undefined
|
|
425
|
+
if( typeof fn === 'function' ){
|
|
426
|
+
const ackFunction = fn
|
|
427
|
+
|
|
428
|
+
cid = ackId()
|
|
429
|
+
this.once(`${_event}--${cid}--@ack`, ({ error, args }) => ackFunction( error, ...args ))
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const messageData = {
|
|
433
|
+
_event,
|
|
434
|
+
payload: sanitizedPayload,
|
|
435
|
+
cid,
|
|
436
|
+
timestamp: Date.now(),
|
|
437
|
+
size: getMessageSize( sanitizedPayload )
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this.peer.webViewRef.current?.postMessage( JSON.stringify( newObject( messageData ) ) )
|
|
441
|
+
}
|
|
442
|
+
catch( error ){
|
|
443
|
+
this.debug(`[${this.peer.type}] Emit error:`, error)
|
|
444
|
+
this.fire('error', {
|
|
445
|
+
type: 'EMIT_ERROR',
|
|
446
|
+
event: _event,
|
|
447
|
+
error: error instanceof Error ? error.message : String(error)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
// Call acknowledgment with error if provided
|
|
451
|
+
typeof fn === 'function'
|
|
452
|
+
&& fn( error instanceof Error ? error.message : String(error) )
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return this
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
on( _event: string, fn: Listener ){
|
|
459
|
+
// Add Event listener
|
|
460
|
+
if( !this.Events[_event] ) this.Events[_event] = []
|
|
461
|
+
this.Events[_event].push( fn )
|
|
462
|
+
|
|
463
|
+
this.debug(`[${this.peer.type}] New <${_event}> listener on`)
|
|
464
|
+
return this
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
once( _event: string, fn: Listener ){
|
|
468
|
+
// Add Once Event listener
|
|
469
|
+
_event += '--@once'
|
|
470
|
+
|
|
471
|
+
if( !this.Events[_event] ) this.Events[_event] = []
|
|
472
|
+
this.Events[_event].push( fn )
|
|
473
|
+
|
|
474
|
+
this.debug(`[${this.peer.type}] New <${_event} once> listener on`)
|
|
475
|
+
return this
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
off( _event: string, fn?: Listener ){
|
|
479
|
+
// Remove Event listener
|
|
480
|
+
if( fn && this.Events[_event] ){
|
|
481
|
+
// Remove specific listener if provided
|
|
482
|
+
const index = this.Events[_event].indexOf( fn )
|
|
483
|
+
if( index > -1 ){
|
|
484
|
+
this.Events[_event].splice( index, 1 )
|
|
485
|
+
|
|
486
|
+
// Remove event array if empty
|
|
487
|
+
if( this.Events[_event].length === 0 )
|
|
488
|
+
delete this.Events[_event]
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// Remove all listeners for event
|
|
492
|
+
else delete this.Events[_event]
|
|
493
|
+
|
|
494
|
+
typeof fn == 'function' && fn()
|
|
495
|
+
this.debug(`[${this.peer.type}] <${_event}> listener off`)
|
|
496
|
+
|
|
497
|
+
return this
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
removeListeners( fn?: Listener ){
|
|
501
|
+
// Clear all event listeners
|
|
502
|
+
this.Events = {}
|
|
503
|
+
typeof fn == 'function' && fn()
|
|
504
|
+
|
|
505
|
+
this.debug(`[${this.peer.type}] All listeners removed`)
|
|
506
|
+
return this
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
emitAsync<T = any, R = any>( _event: string, payload?: T, timeout: number = 5000 ): Promise<R> {
|
|
510
|
+
return new Promise(( resolve, reject ) => {
|
|
511
|
+
const timeoutId = setTimeout(() => {
|
|
512
|
+
reject( new Error(`Event '${_event}' acknowledgment timeout after ${timeout}ms`) )
|
|
513
|
+
}, timeout )
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
this.emit( _event, payload, ( error, ...args ) => {
|
|
517
|
+
clearTimeout( timeoutId )
|
|
518
|
+
|
|
519
|
+
error
|
|
520
|
+
? reject( new Error( typeof error === 'string' ? error : 'Ack error' ) )
|
|
521
|
+
: resolve( args.length === 0 ? undefined : args.length === 1 ? args[0] : args as any )
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
catch( error ){
|
|
525
|
+
clearTimeout( timeoutId )
|
|
526
|
+
reject( error )
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
onceAsync<T = any>( _event: string ): Promise<T> {
|
|
532
|
+
return new Promise( resolve => this.once( _event, resolve ) )
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
connectAsync( timeout?: number ): Promise<void> {
|
|
536
|
+
return new Promise(( resolve, reject ) => {
|
|
537
|
+
if( this.isConnected() ) return resolve()
|
|
538
|
+
|
|
539
|
+
const timeoutId = setTimeout(() => {
|
|
540
|
+
this.off('connect', connectHandler)
|
|
541
|
+
reject( new Error('Connection timeout') )
|
|
542
|
+
}, timeout || this.options.connectionTimeout)
|
|
543
|
+
|
|
544
|
+
const connectHandler = () => {
|
|
545
|
+
clearTimeout( timeoutId )
|
|
546
|
+
resolve()
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
this.once('connect', connectHandler)
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Clean up all resources
|
|
554
|
+
private cleanup(){
|
|
555
|
+
this.stopHeartbeat()
|
|
556
|
+
|
|
557
|
+
if( this.reconnectTimer ){
|
|
558
|
+
clearTimeout( this.reconnectTimer )
|
|
559
|
+
this.reconnectTimer = undefined
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
disconnect( fn?: () => void ){
|
|
564
|
+
// Cleanup on disconnect
|
|
565
|
+
this.cleanup()
|
|
566
|
+
|
|
567
|
+
this.peer.connected = false
|
|
568
|
+
this.peer.webViewRef = undefined
|
|
569
|
+
this.peer.origin = undefined
|
|
570
|
+
this.peer.lastHeartbeat = undefined
|
|
571
|
+
this.messageQueue = []
|
|
572
|
+
this.messageRateTracker = []
|
|
573
|
+
this.reconnectAttempts = 0
|
|
574
|
+
|
|
575
|
+
this.removeListeners()
|
|
576
|
+
|
|
577
|
+
typeof fn == 'function' && fn()
|
|
578
|
+
this.debug(`[${this.peer.type}] Disconnected`)
|
|
579
|
+
|
|
580
|
+
return this
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Get connection statistics
|
|
584
|
+
getStats(){
|
|
585
|
+
return {
|
|
586
|
+
connected: this.isConnected(),
|
|
587
|
+
peerType: this.peer.type,
|
|
588
|
+
origin: this.peer.origin,
|
|
589
|
+
lastHeartbeat: this.peer.lastHeartbeat,
|
|
590
|
+
queuedMessages: this.messageQueue.length,
|
|
591
|
+
reconnectAttempts: this.reconnectAttempts,
|
|
592
|
+
activeListeners: Object.keys( this.Events ).length,
|
|
593
|
+
messageRate: this.messageRateTracker.length
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Clear message queue manually
|
|
598
|
+
clearQueue(){
|
|
599
|
+
const queueSize = this.messageQueue.length
|
|
600
|
+
this.messageQueue = []
|
|
601
|
+
|
|
602
|
+
this.debug(`[${this.peer.type}] Cleared ${queueSize} queued messages`)
|
|
603
|
+
return this
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Get injected JavaScript for WebView
|
|
608
|
+
* Sets up the EMBEDDED side of the bridge
|
|
609
|
+
*/
|
|
610
|
+
getInjectedJavaScript(): string {
|
|
611
|
+
return `
|
|
612
|
+
(function() {
|
|
613
|
+
const RESERVED_EVENTS = ['ping', 'pong', '__heartbeat', '__heartbeat_response'];
|
|
614
|
+
|
|
615
|
+
window._wio = {
|
|
616
|
+
type: 'EMBEDDED',
|
|
617
|
+
connected: false,
|
|
618
|
+
Events: {},
|
|
619
|
+
messageQueue: [],
|
|
620
|
+
|
|
621
|
+
ackId: function(){
|
|
622
|
+
const
|
|
623
|
+
rmin = 100000,
|
|
624
|
+
rmax = 999999,
|
|
625
|
+
timestamp = Date.now(),
|
|
626
|
+
random = Math.floor( Math.random() * ( rmax - rmin + 1 ) + rmin );
|
|
627
|
+
|
|
628
|
+
return timestamp + '_' + random;
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
fire: function( _event, payload, cid ){
|
|
632
|
+
if( !this.Events[_event] && !this.Events[_event + '--@once'] ) return;
|
|
633
|
+
|
|
634
|
+
const ackFn = cid
|
|
635
|
+
? ( error, ...args ) => {
|
|
636
|
+
this.emit( _event + '--' + cid + '--@ack', { error: error || false, args } );
|
|
637
|
+
}
|
|
638
|
+
: undefined;
|
|
639
|
+
|
|
640
|
+
let listeners = [];
|
|
641
|
+
if( this.Events[_event + '--@once'] ){
|
|
642
|
+
_event += '--@once';
|
|
643
|
+
listeners = this.Events[_event];
|
|
644
|
+
delete this.Events[_event];
|
|
645
|
+
}
|
|
646
|
+
else listeners = this.Events[_event] || [];
|
|
647
|
+
|
|
648
|
+
listeners.forEach( fn => {
|
|
649
|
+
try { payload !== undefined ? fn( payload, ackFn ) : fn( ackFn ); }
|
|
650
|
+
catch( error ){ console.error('[EMBEDDED] Listener error:', error); }
|
|
651
|
+
});
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
emit: function( _event, payload, fn ){
|
|
655
|
+
if( typeof payload === 'function' ){
|
|
656
|
+
fn = payload;
|
|
657
|
+
payload = undefined;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if( !this.connected && !RESERVED_EVENTS.includes(_event) ){
|
|
661
|
+
this.messageQueue.push({ _event, payload, fn, timestamp: Date.now() });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
try {
|
|
666
|
+
let cid;
|
|
667
|
+
if( typeof fn === 'function' ){
|
|
668
|
+
cid = this.ackId();
|
|
669
|
+
this.once( _event + '--' + cid + '--@ack', ({ error, args }) => fn( error, ...args ) );
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const messageData = {
|
|
673
|
+
_event,
|
|
674
|
+
payload,
|
|
675
|
+
cid,
|
|
676
|
+
timestamp: Date.now()
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
window.ReactNativeWebView.postMessage( JSON.stringify( messageData ) );
|
|
680
|
+
}
|
|
681
|
+
catch( error ){
|
|
682
|
+
console.error('[EMBEDDED] Emit error:', error);
|
|
683
|
+
typeof fn === 'function' && fn( String(error) );
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
|
|
687
|
+
on: function( _event, fn ){
|
|
688
|
+
if( !this.Events[_event] ) this.Events[_event] = [];
|
|
689
|
+
this.Events[_event].push( fn );
|
|
690
|
+
},
|
|
691
|
+
|
|
692
|
+
once: function( _event, fn ){
|
|
693
|
+
_event += '--@once';
|
|
694
|
+
if( !this.Events[_event] ) this.Events[_event] = [];
|
|
695
|
+
this.Events[_event].push( fn );
|
|
696
|
+
},
|
|
697
|
+
|
|
698
|
+
off: function( _event, fn ){
|
|
699
|
+
if( fn && this.Events[_event] ){
|
|
700
|
+
const index = this.Events[_event].indexOf( fn );
|
|
701
|
+
if( index > -1 ){
|
|
702
|
+
this.Events[_event].splice( index, 1 );
|
|
703
|
+
if( this.Events[_event].length === 0 ) delete this.Events[_event];
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else delete this.Events[_event];
|
|
707
|
+
},
|
|
708
|
+
|
|
709
|
+
processMessageQueue: function(){
|
|
710
|
+
if( !this.connected || this.messageQueue.length === 0 ) return;
|
|
711
|
+
|
|
712
|
+
const queue = [...this.messageQueue];
|
|
713
|
+
this.messageQueue = [];
|
|
714
|
+
|
|
715
|
+
queue.forEach( msg => {
|
|
716
|
+
try { this.emit( msg._event, msg.payload, msg.fn ); }
|
|
717
|
+
catch( error ){ console.error('[EMBEDDED] Queue process error:', error); }
|
|
718
|
+
});
|
|
719
|
+
},
|
|
720
|
+
|
|
721
|
+
handleMessage: function( data ){
|
|
722
|
+
if( !data || !data._event ) return;
|
|
723
|
+
|
|
724
|
+
const { _event, payload, cid } = data;
|
|
725
|
+
|
|
726
|
+
if( _event === '__heartbeat_response' ) return;
|
|
727
|
+
|
|
728
|
+
if( _event === '__heartbeat' ){
|
|
729
|
+
this.emit('__heartbeat_response', { timestamp: Date.now() });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if( _event === 'ping' ){
|
|
734
|
+
this.emit('pong');
|
|
735
|
+
this.connected = true;
|
|
736
|
+
this.processMessageQueue();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
this.fire( _event, payload, cid );
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
// Listen to messages from React Native
|
|
745
|
+
window.addEventListener('message', function( event ){
|
|
746
|
+
try {
|
|
747
|
+
const message = JSON.parse( event.data );
|
|
748
|
+
window._wio.handleMessage( message );
|
|
749
|
+
}
|
|
750
|
+
catch( error ){ console.error('[EMBEDDED] Parse error:', error); }
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// Android support
|
|
754
|
+
if( typeof document !== 'undefined' ){
|
|
755
|
+
document.addEventListener('message', function( event ){
|
|
756
|
+
try {
|
|
757
|
+
const message = JSON.parse( event.data );
|
|
758
|
+
window._wio.handleMessage( message );
|
|
759
|
+
}
|
|
760
|
+
catch( error ){ console.error('[EMBEDDED] Parse error:', error); }
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
true;
|
|
765
|
+
})();
|
|
766
|
+
`;
|
|
767
|
+
}
|
|
768
|
+
}
|