api-ape 3.0.2 → 4.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 (186) hide show
  1. package/README.md +59 -572
  2. package/client/README.md +73 -14
  3. package/client/auth/crypto/aead.js +214 -0
  4. package/client/auth/crypto/constants.js +32 -0
  5. package/client/auth/crypto/encoding.js +104 -0
  6. package/client/auth/crypto/files.md +27 -0
  7. package/client/auth/crypto/kdf.js +217 -0
  8. package/client/auth/crypto-utils.js +118 -0
  9. package/client/auth/files.md +52 -0
  10. package/client/auth/key-recovery.js +288 -0
  11. package/client/auth/recovery/constants.js +37 -0
  12. package/client/auth/recovery/files.md +23 -0
  13. package/client/auth/recovery/key-derivation.js +61 -0
  14. package/client/auth/recovery/sss-browser.js +189 -0
  15. package/client/auth/share-storage.js +205 -0
  16. package/client/auth/storage/constants.js +18 -0
  17. package/client/auth/storage/db.js +132 -0
  18. package/client/auth/storage/files.md +27 -0
  19. package/client/auth/storage/keys.js +173 -0
  20. package/client/auth/storage/shares.js +200 -0
  21. package/client/browser.js +190 -23
  22. package/client/connectSocket.js +418 -988
  23. package/client/connection/README.md +23 -0
  24. package/client/connection/fileDownload.js +256 -0
  25. package/client/connection/fileHandling.js +450 -0
  26. package/client/connection/fileUtils.js +346 -0
  27. package/client/connection/files.md +71 -0
  28. package/client/connection/messageHandler.js +105 -0
  29. package/client/connection/network.js +350 -0
  30. package/client/connection/proxy.js +233 -0
  31. package/client/connection/sender.js +333 -0
  32. package/client/connection/state.js +321 -0
  33. package/client/connection/subscriptions.js +151 -0
  34. package/client/files.md +53 -0
  35. package/client/index.js +298 -142
  36. package/client/transports/README.md +50 -0
  37. package/client/transports/files.md +41 -0
  38. package/client/transports/streamParser.js +195 -0
  39. package/client/transports/streaming.js +555 -203
  40. package/dist/ape.js +6 -1
  41. package/dist/ape.js.map +4 -4
  42. package/index.d.ts +38 -16
  43. package/package.json +31 -6
  44. package/server/README.md +272 -67
  45. package/server/adapters/README.md +23 -14
  46. package/server/adapters/files.md +68 -0
  47. package/server/adapters/firebase.js +543 -160
  48. package/server/adapters/index.js +362 -112
  49. package/server/adapters/mongo.js +530 -140
  50. package/server/adapters/postgres.js +534 -155
  51. package/server/adapters/redis.js +508 -143
  52. package/server/adapters/supabase.js +555 -186
  53. package/server/client/README.md +43 -0
  54. package/server/client/connection.js +586 -0
  55. package/server/client/files.md +40 -0
  56. package/server/client/index.js +342 -0
  57. package/server/files.md +54 -0
  58. package/server/index.js +322 -71
  59. package/server/lib/README.md +26 -0
  60. package/server/lib/broadcast/clients.js +219 -0
  61. package/server/lib/broadcast/files.md +58 -0
  62. package/server/lib/broadcast/index.js +57 -0
  63. package/server/lib/broadcast/publishProxy.js +110 -0
  64. package/server/lib/broadcast/pubsub.js +137 -0
  65. package/server/lib/broadcast/sendProxy.js +103 -0
  66. package/server/lib/bun.js +315 -99
  67. package/server/lib/fileTransfer/README.md +63 -0
  68. package/server/lib/fileTransfer/files.md +30 -0
  69. package/server/lib/fileTransfer/streaming.js +435 -0
  70. package/server/lib/fileTransfer.js +710 -326
  71. package/server/lib/files.md +111 -0
  72. package/server/lib/httpUtils.js +283 -0
  73. package/server/lib/loader.js +208 -7
  74. package/server/lib/longPolling/README.md +63 -0
  75. package/server/lib/longPolling/files.md +44 -0
  76. package/server/lib/longPolling/getHandler.js +365 -0
  77. package/server/lib/longPolling/postHandler.js +327 -0
  78. package/server/lib/longPolling.js +174 -219
  79. package/server/lib/main.js +369 -532
  80. package/server/lib/runtimes/README.md +42 -0
  81. package/server/lib/runtimes/bun.js +586 -0
  82. package/server/lib/runtimes/files.md +56 -0
  83. package/server/lib/runtimes/node.js +511 -0
  84. package/server/lib/wiring.js +539 -98
  85. package/server/lib/ws/README.md +35 -0
  86. package/server/lib/ws/adapters/README.md +54 -0
  87. package/server/lib/ws/adapters/bun.js +538 -170
  88. package/server/lib/ws/adapters/deno.js +623 -149
  89. package/server/lib/ws/adapters/files.md +42 -0
  90. package/server/lib/ws/files.md +74 -0
  91. package/server/lib/ws/frames.js +532 -154
  92. package/server/lib/ws/index.js +207 -10
  93. package/server/lib/ws/server.js +385 -92
  94. package/server/lib/ws/socket.js +549 -181
  95. package/server/lib/wsProvider.js +363 -89
  96. package/server/plugins/binary.js +282 -0
  97. package/server/security/README.md +92 -0
  98. package/server/security/auth/README.md +319 -0
  99. package/server/security/auth/adapters/files.md +95 -0
  100. package/server/security/auth/adapters/ldap/constants.js +37 -0
  101. package/server/security/auth/adapters/ldap/files.md +19 -0
  102. package/server/security/auth/adapters/ldap/helpers.js +111 -0
  103. package/server/security/auth/adapters/ldap.js +353 -0
  104. package/server/security/auth/adapters/oauth2/constants.js +41 -0
  105. package/server/security/auth/adapters/oauth2/files.md +19 -0
  106. package/server/security/auth/adapters/oauth2/helpers.js +123 -0
  107. package/server/security/auth/adapters/oauth2.js +273 -0
  108. package/server/security/auth/adapters/opaque-handlers.js +314 -0
  109. package/server/security/auth/adapters/opaque.js +205 -0
  110. package/server/security/auth/adapters/saml/constants.js +52 -0
  111. package/server/security/auth/adapters/saml/files.md +19 -0
  112. package/server/security/auth/adapters/saml/helpers.js +74 -0
  113. package/server/security/auth/adapters/saml.js +173 -0
  114. package/server/security/auth/adapters/totp.js +703 -0
  115. package/server/security/auth/adapters/webauthn.js +625 -0
  116. package/server/security/auth/files.md +61 -0
  117. package/server/security/auth/framework/constants.js +27 -0
  118. package/server/security/auth/framework/files.md +23 -0
  119. package/server/security/auth/framework/handlers.js +272 -0
  120. package/server/security/auth/framework/socket-auth.js +177 -0
  121. package/server/security/auth/handlers/auth-messages.js +143 -0
  122. package/server/security/auth/handlers/files.md +28 -0
  123. package/server/security/auth/index.js +290 -0
  124. package/server/security/auth/mfa/crypto/aead.js +148 -0
  125. package/server/security/auth/mfa/crypto/constants.js +35 -0
  126. package/server/security/auth/mfa/crypto/files.md +27 -0
  127. package/server/security/auth/mfa/crypto/kdf.js +120 -0
  128. package/server/security/auth/mfa/crypto/utils.js +68 -0
  129. package/server/security/auth/mfa/crypto-utils.js +80 -0
  130. package/server/security/auth/mfa/files.md +77 -0
  131. package/server/security/auth/mfa/ledger/constants.js +75 -0
  132. package/server/security/auth/mfa/ledger/errors.js +73 -0
  133. package/server/security/auth/mfa/ledger/files.md +23 -0
  134. package/server/security/auth/mfa/ledger/share-record.js +32 -0
  135. package/server/security/auth/mfa/ledger.js +255 -0
  136. package/server/security/auth/mfa/recovery/constants.js +67 -0
  137. package/server/security/auth/mfa/recovery/files.md +19 -0
  138. package/server/security/auth/mfa/recovery/handlers.js +216 -0
  139. package/server/security/auth/mfa/recovery.js +191 -0
  140. package/server/security/auth/mfa/sss/constants.js +21 -0
  141. package/server/security/auth/mfa/sss/files.md +23 -0
  142. package/server/security/auth/mfa/sss/gf256.js +103 -0
  143. package/server/security/auth/mfa/sss/serialization.js +82 -0
  144. package/server/security/auth/mfa/sss.js +161 -0
  145. package/server/security/auth/mfa/two-of-three/constants.js +58 -0
  146. package/server/security/auth/mfa/two-of-three/files.md +23 -0
  147. package/server/security/auth/mfa/two-of-three/handlers.js +241 -0
  148. package/server/security/auth/mfa/two-of-three/helpers.js +71 -0
  149. package/server/security/auth/mfa/two-of-three.js +136 -0
  150. package/server/security/auth/nonce-manager.js +89 -0
  151. package/server/security/auth/state-machine-mfa.js +269 -0
  152. package/server/security/auth/state-machine.js +257 -0
  153. package/server/security/extractRootDomain.js +144 -16
  154. package/server/security/files.md +51 -0
  155. package/server/security/origin.js +197 -15
  156. package/server/security/reply.js +274 -16
  157. package/server/socket/README.md +119 -0
  158. package/server/socket/authMiddleware.js +299 -0
  159. package/server/socket/files.md +86 -0
  160. package/server/socket/open.js +154 -8
  161. package/server/socket/pluginHooks.js +334 -0
  162. package/server/socket/receive.js +184 -224
  163. package/server/socket/receiveContext.js +117 -0
  164. package/server/socket/send.js +416 -78
  165. package/server/socket/tagUtils.js +402 -0
  166. package/server/utils/README.md +19 -0
  167. package/server/utils/deepRequire.js +255 -30
  168. package/server/utils/files.md +57 -0
  169. package/server/utils/genId.js +182 -20
  170. package/server/utils/parseUserAgent.js +313 -251
  171. package/server/utils/userAgent/README.md +65 -0
  172. package/server/utils/userAgent/files.md +46 -0
  173. package/server/utils/userAgent/patterns.js +545 -0
  174. package/utils/README.md +21 -0
  175. package/utils/files.md +66 -0
  176. package/utils/jss/README.md +21 -0
  177. package/utils/jss/decode.js +471 -0
  178. package/utils/jss/encode.js +312 -0
  179. package/utils/jss/files.md +68 -0
  180. package/utils/jss/plugins.js +210 -0
  181. package/utils/jss.js +219 -273
  182. package/utils/messageHash.js +238 -35
  183. package/dist/api-ape.min.js +0 -2
  184. package/dist/api-ape.min.js.map +0 -7
  185. package/server/client.js +0 -311
  186. package/server/lib/broadcast.js +0 -146
@@ -1,1089 +1,519 @@
1
- import messageHash from '../utils/messageHash'
2
- import jss from '../utils/jss'
3
- import { createStreamingTransport } from './transports/streaming'
4
-
5
- let connect;
6
-
7
- // Connection state enum
8
- const ConnectionState = {
9
- Offline: 'offline', // navigator.onLine = false
10
- Walled: 'walled', // Captive portal detected (ping failed)
11
- Disconnected: 'disconnected',
12
- Connecting: 'connecting',
13
- Connected: 'connected',
14
- Closing: 'closing'
15
- }
16
-
17
- // Connection state tracking - start with offline check
18
- let connectionState = (typeof navigator !== 'undefined' && !navigator.onLine)
19
- ? ConnectionState.Offline
20
- : ConnectionState.Disconnected
21
- const connectionChangeListeners = []
22
-
23
- function notifyConnectionChange(newState) {
24
- if (connectionState !== newState) {
25
- connectionState = newState
26
- connectionChangeListeners.forEach(fn => fn(newState))
27
- }
28
- }
29
-
30
- // Configuration
31
- let configuredTransport = 'auto' // 'auto' | 'websocket' | 'polling'
1
+ /**
2
+ * @fileoverview Core client socket connection module for api-ape
3
+ *
4
+ * This module manages WebSocket connections with automatic fallback to HTTP streaming
5
+ * when WebSocket connections fail or are blocked (e.g., by corporate firewalls).
6
+ *
7
+ * ## Connection Flow
8
+ * 1. Attempts WebSocket connection first (preferred for low latency)
9
+ * 2. Falls back to HTTP streaming if WebSocket fails within 4 seconds
10
+ * 3. Periodically retries WebSocket even when using HTTP streaming
11
+ * 4. Handles reconnection automatically when connections drop
12
+ *
13
+ * ## Transport Modes
14
+ * - `websocket` - Real-time bidirectional WebSocket connection
15
+ * - `polling` - HTTP streaming fallback (GET for receiving, POST for sending)
16
+ * - `auto` - Automatically selects best transport (default)
17
+ *
18
+ * ## Binary Data Support
19
+ * The module transparently handles binary data (ArrayBuffer, Blob) by:
20
+ * - Converting binary payloads to HTTP uploads
21
+ * - Hydrating responses with linked binary resources
22
+ * - Supporting client-to-client file sharing
23
+ *
24
+ * @module client/connectSocket
25
+ * @see {@link module:client/connection/state} for connection state management
26
+ * @see {@link module:client/transports/streaming} for HTTP fallback transport
27
+ *
28
+ * @example
29
+ * // Basic usage
30
+ * import connectSocket from './connectSocket.js'
31
+ *
32
+ * const client = connectSocket()
33
+ * connectSocket.autoReconnect()
34
+ *
35
+ * // Send messages
36
+ * client.sender.chat({ message: 'Hello!' })
37
+ * .then(response => console.log(response))
38
+ *
39
+ * // Receive broadcasts
40
+ * client.setOnReceiver('notification', (msg) => {
41
+ * console.log('Received:', msg.data)
42
+ * })
43
+ *
44
+ * // Monitor connection state
45
+ * client.onConnectionChange((state) => {
46
+ * console.log('Connection state:', state)
47
+ * })
48
+ */
32
49
 
33
- // Transport state
34
- let currentTransport = null // 'websocket' | 'polling'
35
- let streamingTransport = null
36
- let wsRetryTimer = null
37
- let networkCheckTimer = null
38
- const WS_FALLBACK_TIMEOUT = 4000 // Time to wait for WS before fallback
39
- const WS_RETRY_INTERVAL = 30000 // Retry WebSocket while in polling mode
40
- const PING_TIMEOUT = 3000 // Timeout for ping check
41
- const MAX_PING_CLOCK_SKEW = 60000 // Max allowed time difference (60s)
50
+ import jss from "../utils/jss";
51
+ import { createStreamingTransport } from "./transports/streaming";
52
+ import {
53
+ ConnectionState,
54
+ notifyConnectionChange,
55
+ onConnectionChange,
56
+ } from "./connection/state";
57
+ import {
58
+ getSocketUrl,
59
+ checkCaptivePortal,
60
+ scheduleNetworkRetry,
61
+ setupOnlineListeners,
62
+ WS_RETRY_INTERVAL,
63
+ } from "./connection/network";
64
+ import { wrap } from "./connection/proxy";
65
+ import { createWsSend, createSender } from "./connection/sender";
66
+ import { setSendFn, resubscribeAll } from "./connection/subscriptions";
67
+ import {
68
+ processIncomingData,
69
+ dispatchMessage,
70
+ setOnReceiver,
71
+ } from "./connection/messageHandler";
42
72
 
43
73
  /**
44
- * Check if running in dev/local mode
74
+ * Configured transport mode
75
+ * @type {'auto'|'websocket'|'polling'}
76
+ * @private
45
77
  */
46
- function isDevMode() {
47
- if (typeof window === 'undefined') return false
48
- return ['localhost', '127.0.0.1', '[::1]'].includes(window.location.hostname)
49
- }
78
+ let configuredTransport = "auto";
50
79
 
51
80
  /**
52
- * Build ping URL for captive portal detection
81
+ * Currently active transport type
82
+ * @type {'websocket'|'polling'|null}
83
+ * @private
53
84
  */
54
- function getPingUrl() {
55
- const hostname = window.location.hostname
56
- const isHttps = window.location.protocol === 'https:'
57
- const port = window.location.port || (isHttps ? 443 : 80)
58
- const protocol = isHttps ? 'https' : 'http'
59
- const portSuffix = (port !== 80 && port !== 443) ? `:${port}` : ''
60
- return `${protocol}://${hostname}${portSuffix}/api/ape/ping`
61
- }
85
+ let currentTransport = null;
62
86
 
63
87
  /**
64
- * Check for captive portal by pinging /api/ape/ping
65
- * Returns 'ok' if real internet, 'walled' if captive portal detected
88
+ * HTTP streaming transport instance (created lazily)
89
+ * @type {import('./transports/streaming').StreamingTransport|null}
90
+ * @private
66
91
  */
67
- async function checkCaptivePortal() {
68
- try {
69
- const controller = new AbortController()
70
- const timeoutId = setTimeout(() => controller.abort(), PING_TIMEOUT)
71
-
72
- const response = await fetch(getPingUrl(), {
73
- cache: 'no-store',
74
- signal: controller.signal
75
- })
76
- clearTimeout(timeoutId)
77
-
78
- if (!response.ok) {
79
- if (isDevMode()) {
80
- console.error('🦍 [DEV] Ping failed: HTTP', response.status)
81
- }
82
- return 'walled'
83
- }
84
-
85
- const data = await response.json()
86
-
87
- // Verify response is genuine (not a captive portal redirect page)
88
- if (data?.ok !== true) {
89
- if (isDevMode()) {
90
- console.error('🦍 [DEV] Ping failed: invalid response', data)
91
- }
92
- return 'walled'
93
- }
94
-
95
- // Validate timestamp to detect proxy replay attacks
96
- if (typeof data.ts === 'number') {
97
- const now = Date.now()
98
- const skew = Math.abs(now - data.ts)
99
- if (skew > MAX_PING_CLOCK_SKEW) {
100
- if (isDevMode()) {
101
- console.error('🦍 [DEV] Ping failed: timestamp too old/stale (skew:', skew, 'ms)')
102
- }
103
- return 'walled'
104
- }
105
- }
106
-
107
- return 'ok'
108
- } catch (err) {
109
- if (isDevMode()) {
110
- console.error('🦍 [DEV] Ping failed:', err.message || err)
111
- }
112
- return 'walled'
113
- }
114
- }
92
+ let streamingTransport = null;
115
93
 
116
94
  /**
117
- * Setup navigator.onLine event listeners
95
+ * Timer for periodic WebSocket retry attempts
96
+ * @type {number|null}
97
+ * @private
118
98
  */
119
- function setupOnlineListeners() {
120
- if (typeof window === 'undefined') return
121
-
122
- window.addEventListener('online', () => {
123
- console.log('🦍 Browser went online, checking network...')
124
- // Trigger reconnection attempt
125
- attemptConnection()
126
- })
127
-
128
- window.addEventListener('offline', () => {
129
- console.log('🦍 Browser went offline')
130
- notifyConnectionChange(ConnectionState.Offline)
131
- })
132
- }
133
-
134
- // Setup listeners on module load (browser only)
135
- if (typeof window !== 'undefined') {
136
- setupOnlineListeners()
137
- }
138
-
139
-
99
+ let wsRetryTimer = null;
140
100
 
141
101
  /**
142
- * Get WebSocket URL - auto-detects from window.location, keeps /api/ape path
102
+ * Timeout before falling back to HTTP streaming (ms)
103
+ * @constant {number}
143
104
  */
144
- function getSocketUrl() {
145
- const hostname = window.location.hostname
146
- const localServers = ["localhost", "127.0.0.1", "[::1]"]
147
- const isLocal = localServers.includes(hostname)
148
- const isHttps = window.location.protocol === "https:"
149
-
150
- // Use window.location.port if available, otherwise fallback (9010 for local dev, 443/80 for prod)
151
- const port = window.location.port || (isLocal ? 9010 : (isHttps ? 443 : 80))
152
-
153
- // Build URL - keep /api/ape path
154
- const protocol = isHttps ? "wss" : "ws"
155
- const portSuffix = (port !== 80 && port !== 443) ? `:${port}` : ""
156
-
157
- return `${protocol}://${hostname}${portSuffix}/api/ape`
158
- }
159
-
160
- let reconnect = false
161
- const connectTimeout = 5000
162
- const totalRequestTimeout = 10000
163
- //const location = window.location
164
-
165
- const joinKey = "/"
166
- // Properties accessed directly on `ape` that should NOT be intercepted
167
- const reservedKeys = new Set(['on', 'onConnectionChange', 'transport'])
168
- const handler = {
169
- get(fn, key) {
170
- // Skip proxy interception for reserved keys - return actual property
171
- if (reservedKeys.has(key)) {
172
- return fn[key]
173
- }
174
- const wrapperFn = function (a, b) {
175
- let path = joinKey + key, body;
176
- if (2 === arguments.length) {
177
- path += a
178
- body = b
179
- } else {
180
- body = a
181
- }
182
- return fn(path, body)
183
- }
184
- return new Proxy(wrapperFn, handler)
185
- } // END get
186
- }
187
-
188
- function wrap(api) {
189
- return new Proxy(api, handler)
190
- }
191
-
192
- let __socket = false, ready = false, wsSend = false;
193
- const waitingOn = {};
194
-
195
- let aWaitingSend = []
196
- const receiverArray = [];
197
- const ofTypesOb = {};
105
+ const WS_FALLBACK_TIMEOUT = 4000;
198
106
 
199
107
  /**
200
- * Switch to streaming transport (HTTP long polling fallback)
108
+ * Current WebSocket instance, or false if not connected
109
+ * @type {WebSocket|false}
110
+ * @private
201
111
  */
202
- function switchToStreaming() {
203
- console.log('🦍 Switching to HTTP streaming transport')
204
- currentTransport = 'polling'
205
-
206
- if (!streamingTransport) {
207
- streamingTransport = createStreamingTransport()
208
-
209
- // Handle incoming messages from streaming transport
210
- streamingTransport.onMessage = async (msg) => {
211
- const { err, type, data } = msg
212
-
213
- // Process linked resources and shared files
214
- let processedData = data
215
- if (data && !err) {
216
- try {
217
- processedData = await fetchLinkedResources(data)
218
- processedData = await fetchSharedFiles(processedData)
219
- } catch (fetchErr) {
220
- console.error(`🦍 Failed to hydrate streaming data:`, fetchErr)
221
- }
222
- }
223
-
224
- // Dispatch to type-specific handlers
225
- if (ofTypesOb[type]) {
226
- ofTypesOb[type].forEach(worker => worker({ err, type, data: processedData }))
227
- }
228
- // Dispatch to general handlers
229
- receiverArray.forEach(worker => worker({ err, type, data: processedData }))
230
- }
231
-
232
- streamingTransport.onOpen = () => {
233
- ready = true
234
- notifyConnectionChange(ConnectionState.Connected)
235
- console.log('🦍 HTTP streaming connected')
236
-
237
- // Flush waiting messages
238
- aWaitingSend.forEach(({ type, data, resolve, reject, waiting, createdAt, timer }) => {
239
- clearTimeout(timer)
240
- const resultPromise = streamingSend(type, data, createdAt)
241
- if (waiting) {
242
- resultPromise.then(resolve).catch(reject)
243
- }
244
- })
245
- aWaitingSend = []
246
-
247
- // Start background WebSocket retry
248
- startWsRetry()
249
- }
250
-
251
- streamingTransport.onClose = () => {
252
- ready = false
253
- notifyConnectionChange(ConnectionState.Disconnected)
254
- }
255
-
256
- streamingTransport.onError = (err) => {
257
- console.error('🦍 Streaming error:', err)
258
- }
259
- }
260
-
261
- streamingTransport.connect()
262
- }
112
+ let __socket = false;
263
113
 
264
114
  /**
265
- * Send via streaming transport
115
+ * Whether the connection is ready to send/receive messages
116
+ * @type {boolean}
117
+ * @private
266
118
  */
267
- function streamingSend(type, data, createdAt) {
268
- return streamingTransport.send(type, data, createdAt)
269
- }
119
+ let ready = false;
270
120
 
271
121
  /**
272
- * Start background retry for WebSocket (while in polling mode)
122
+ * Map of pending query IDs to their response callbacks
123
+ * Used to match responses to their original requests
124
+ * @type {Object.<string, function(Error|null, any): void>}
125
+ * @private
273
126
  */
274
- function startWsRetry() {
275
- if (wsRetryTimer) return
276
- if (currentTransport !== 'polling') return
277
- if (configuredTransport === 'polling') return // User explicitly wants polling only
278
-
279
- wsRetryTimer = setInterval(() => {
280
- if (currentTransport !== 'polling') {
281
- clearInterval(wsRetryTimer)
282
- wsRetryTimer = null
283
- return
284
- }
285
-
286
- console.log('🦍 Attempting WebSocket reconnection...')
287
- tryWebSocket(true)
288
- }, WS_RETRY_INTERVAL)
289
- }
127
+ const waitingOn = {};
290
128
 
291
129
  /**
292
- * Try to establish WebSocket connection
293
- * @param {boolean} isRetry - If true, this is a background retry attempt
130
+ * Queue of messages waiting to be sent when connection becomes ready
131
+ * @type {Array<{type: string, data: any, resolve: function, reject: function, waiting: boolean, createdAt: number, timer: number}>}
132
+ * @private
294
133
  */
295
- function tryWebSocket(isRetry = false) {
296
- const ws = new WebSocket(getSocketUrl())
297
- let fallbackTimer = null
298
-
299
- // Set fallback timeout (only for initial connection, not retries)
300
- if (!isRetry && configuredTransport === 'auto') {
301
- fallbackTimer = setTimeout(() => {
302
- if (ws.readyState !== WebSocket.OPEN) {
303
- console.log('🦍 WebSocket timeout, falling back to HTTP streaming')
304
- ws.close()
305
- switchToStreaming()
306
- }
307
- }, WS_FALLBACK_TIMEOUT)
308
- }
309
-
310
- ws.onopen = () => {
311
- if (fallbackTimer) clearTimeout(fallbackTimer)
312
-
313
- // If this is a retry and we're in polling mode, switch back to WebSocket
314
- if (isRetry && currentTransport === 'polling') {
315
- console.log('🦍 WebSocket reconnected, switching from HTTP streaming')
316
- if (streamingTransport) {
317
- streamingTransport.close()
318
- }
319
- if (wsRetryTimer) {
320
- clearInterval(wsRetryTimer)
321
- wsRetryTimer = null
322
- }
323
- }
324
-
325
- currentTransport = 'websocket'
326
- __socket = ws
327
- ready = true
328
- notifyConnectionChange(ConnectionState.Connected)
329
-
330
- aWaitingSend.forEach(({ type, data, resolve, reject, waiting, createdAt, timer }) => {
331
- clearTimeout(timer)
332
- const resultPromise = wsSend(type, data, createdAt)
333
- if (waiting) {
334
- resultPromise.then(resolve).catch(reject)
335
- }
336
- })
337
- aWaitingSend = []
338
- }
339
-
340
- ws.onmessage = async function (event) {
341
- const { err, type, queryId, data } = jss.parse(event.data)
342
-
343
- // Messages with queryId must fulfill matching promise
344
- if (queryId) {
345
- if (waitingOn[queryId]) {
346
- // Check for linked resources and fetch them before resolving
347
- if (data && !err) {
348
- try {
349
- let hydratedData = await fetchLinkedResources(data)
350
- hydratedData = await fetchSharedFiles(hydratedData)
351
- waitingOn[queryId](err, hydratedData)
352
- } catch (fetchErr) {
353
- waitingOn[queryId](fetchErr, null)
354
- }
355
- } else {
356
- waitingOn[queryId](err, data)
357
- }
358
- delete waitingOn[queryId]
359
- } else {
360
- console.error(`🦍 No matching queryId: ${queryId}`)
361
- }
362
- return
363
- }
364
-
365
- // Only messages WITHOUT queryId go to setOnReceiver
366
- let processedData = data
367
- if (data && !err) {
368
- try {
369
- processedData = await fetchLinkedResources(data)
370
- processedData = await fetchSharedFiles(processedData)
371
- } catch (fetchErr) {
372
- console.error(`🦍 Failed to hydrate broadcast data:`, fetchErr)
373
- }
374
- }
375
-
376
- if (ofTypesOb[type]) {
377
- ofTypesOb[type].forEach(worker => worker({ err, type, data: processedData }))
378
- }
379
- receiverArray.forEach(worker => worker({ err, type, data: processedData }))
380
- }
381
-
382
- ws.onerror = function (err) {
383
- if (fallbackTimer) clearTimeout(fallbackTimer)
384
- console.error('socket ERROR:', err)
385
-
386
- // On initial connection error in auto mode, fallback to streaming
387
- if (!isRetry && configuredTransport === 'auto' && !ready) {
388
- switchToStreaming()
389
- }
390
- }
391
-
392
- ws.onclose = function (event) {
393
- if (fallbackTimer) clearTimeout(fallbackTimer)
394
- console.warn('socket disconnect:', event)
395
- __socket = false
396
- ready = false
397
-
398
- // Only notify disconnected if we're on websocket transport
399
- if (currentTransport === 'websocket') {
400
- notifyConnectionChange(ConnectionState.Disconnected)
401
- setTimeout(() => reconnect && connectSocket(), 500)
402
- }
403
- }
404
- }
134
+ let aWaitingSend = [];
405
135
 
406
136
  /**
407
- * Find all L-tagged (binary link) properties in data
408
- * Returns array of { path, hash }
137
+ * Whether auto-reconnect is enabled
138
+ * @type {boolean}
139
+ * @private
409
140
  */
410
- function findLinkedResources(obj, path = '') {
411
- const resources = []
412
-
413
- if (obj === null || obj === undefined || typeof obj !== 'object') {
414
- return resources
415
- }
416
-
417
- if (Array.isArray(obj)) {
418
- for (let i = 0; i < obj.length; i++) {
419
- resources.push(...findLinkedResources(obj[i], path ? `${path}.${i}` : String(i)))
420
- }
421
- return resources
422
- }
423
-
424
- for (const key of Object.keys(obj)) {
425
- // Check for L-tag in key (from JJS encoding: key<!L>)
426
- if (key.endsWith('<!L>')) {
427
- const cleanKey = key.slice(0, -4)
428
- const hash = obj[key]
429
- resources.push({
430
- path: path ? `${path}.${cleanKey}` : cleanKey,
431
- hash,
432
- originalKey: key
433
- })
434
- } else {
435
- resources.push(...findLinkedResources(obj[key], path ? `${path}.${key}` : key))
436
- }
437
- }
438
-
439
- return resources
440
- }
141
+ let reconnect = false;
441
142
 
442
143
  /**
443
- * Find all F-tagged (shared file) properties in data
444
- * Returns array of { path, hash, originalKey }
144
+ * WebSocket send function bound to current socket
145
+ * @type {function(string, any, number, boolean=): Promise<any>}
146
+ * @private
445
147
  */
446
- function findFileTags(obj, path = '') {
447
- const files = []
448
-
449
- if (obj === null || obj === undefined || typeof obj !== 'object') {
450
- return files
451
- }
148
+ const wsSend = createWsSend(() => __socket, waitingOn);
452
149
 
453
- if (Array.isArray(obj)) {
454
- for (let i = 0; i < obj.length; i++) {
455
- files.push(...findFileTags(obj[i], path ? `${path}.${i}` : String(i)))
456
- }
457
- return files
458
- }
459
-
460
- for (const key of Object.keys(obj)) {
461
- // Check for F-tag in key (client-to-client shared file marker)
462
- if (key.endsWith('<!F>')) {
463
- const cleanKey = key.slice(0, -4)
464
- const hash = obj[key]
465
- files.push({
466
- path: path ? `${path}.${cleanKey}` : cleanKey,
467
- hash,
468
- originalKey: key
469
- })
470
- } else {
471
- files.push(...findFileTags(obj[key], path ? `${path}.${key}` : key))
472
- }
473
- }
474
-
475
- return files
150
+ // Setup browser online/offline listeners on module load
151
+ if (typeof window !== "undefined") {
152
+ setupOnlineListeners(attemptConnection);
476
153
  }
477
154
 
478
155
  /**
479
- * Clean up F-tagged keys (rename key<!F> to key)
156
+ * Flush all queued messages through the provided send function
157
+ *
158
+ * Called when connection becomes ready to send pending messages
159
+ * that were queued while disconnected.
160
+ *
161
+ * @param {function(string, any, number): Promise<any>} sendFn - Send function to use
162
+ * @private
480
163
  */
481
- function cleanFileTags(obj) {
482
- if (obj === null || obj === undefined || typeof obj !== 'object') {
483
- return obj
484
- }
485
-
486
- if (Array.isArray(obj)) {
487
- return obj.map(cleanFileTags)
488
- }
489
-
490
- const cleaned = {}
491
- for (const key of Object.keys(obj)) {
492
- if (key.endsWith('<!F>')) {
493
- const cleanKey = key.slice(0, -4)
494
- cleaned[cleanKey] = obj[key]
495
- } else {
496
- cleaned[key] = cleanFileTags(obj[key])
497
- }
498
- }
499
- return cleaned
164
+ function flushWaitingMessages(sendFn) {
165
+ aWaitingSend.forEach(
166
+ ({ type, data, resolve, reject, waiting, createdAt, timer }) => {
167
+ clearTimeout(timer);
168
+ const result = sendFn(type, data, createdAt);
169
+ if (waiting) result.then(resolve).catch(reject);
170
+ },
171
+ );
172
+ aWaitingSend = [];
500
173
  }
501
174
 
502
175
  /**
503
- * Fetch shared files (client-to-client transfers)
504
- * Retries if upload is still in progress
176
+ * Switch from WebSocket to HTTP streaming transport
177
+ *
178
+ * Creates the streaming transport if needed and sets up event handlers.
179
+ * This is called when WebSocket connection fails or times out.
180
+ *
181
+ * @private
505
182
  */
506
- async function fetchSharedFiles(data, maxRetries = 5) {
507
- const files = findFileTags(data)
508
-
509
- if (files.length === 0) {
510
- return data
511
- }
512
-
513
- console.log(`🦍 Fetching ${files.length} shared file(s)`)
514
-
515
- const cleanedData = cleanFileTags(data)
516
-
517
- const hostname = window.location.hostname
518
- const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(hostname)
519
- const isHttps = window.location.protocol === "https:"
520
- const port = window.location.port || (isLocal ? 9010 : (isHttps ? 443 : 80))
521
- const protocol = isHttps ? "https" : "http"
522
- const portSuffix = (port !== 80 && port !== 443) ? `:${port}` : ""
523
- const baseUrl = `${protocol}://${hostname}${portSuffix}`
524
-
525
- await Promise.all(files.map(async ({ path, hash }) => {
526
- let retries = 0
527
- let backoff = 100 // Start with 100ms
183
+ function switchToStreaming() {
184
+ console.log("🦍 Switching to HTTP streaming transport");
185
+ currentTransport = "polling";
528
186
 
529
- while (retries < maxRetries) {
530
- try {
531
- const response = await fetch(`${baseUrl}/api/ape/data/${hash}`, {
532
- credentials: 'include'
533
- })
187
+ if (!streamingTransport) {
188
+ streamingTransport = createStreamingTransport();
534
189
 
535
- if (!response.ok) {
536
- // 404 might mean file not uploaded yet, retry
537
- if (response.status === 404 && retries < maxRetries - 1) {
538
- retries++
539
- await new Promise(r => setTimeout(r, backoff))
540
- backoff *= 2 // Exponential backoff
541
- continue
542
- }
543
- throw new Error(`Failed to fetch shared file: ${response.status}`)
544
- }
190
+ /**
191
+ * Handle incoming messages from streaming transport
192
+ * @param {{type: string, data: any, err: any}} msg - Parsed message
193
+ */
194
+ streamingTransport.onMessage = async (msg) => {
195
+ const data = await processIncomingData(msg.data, msg.err);
196
+ dispatchMessage(msg.type, msg.err, data);
197
+ };
545
198
 
546
- const arrayBuffer = await response.arrayBuffer()
547
- setValueAtPath(cleanedData, path, arrayBuffer)
199
+ /**
200
+ * Handle streaming connection established
201
+ */
202
+ streamingTransport.onOpen = () => {
203
+ ready = true;
548
204
 
549
- // Check if upload is still in progress
550
- const isComplete = response.headers.get('X-Ape-Complete') === '1'
551
- if (!isComplete) {
552
- console.log(`🦍 Shared file ${hash} still uploading (${response.headers.get('X-Ape-Total-Received') || '?'} bytes)`)
553
- }
554
- break
555
- } catch (err) {
556
- if (retries >= maxRetries - 1) {
557
- console.error(`🦍 Failed to fetch shared file at ${path}:`, err)
558
- setValueAtPath(cleanedData, path, null)
559
- }
560
- retries++
561
- await new Promise(r => setTimeout(r, backoff))
562
- backoff *= 2
563
- }
564
- }
565
- }))
205
+ // Set up subscription send function for streaming and re-subscribe
206
+ setSendFn((msg) => streamingTransport.sendRaw(msg));
207
+ resubscribeAll();
566
208
 
567
- return cleanedData
568
- }
209
+ notifyConnectionChange(ConnectionState.Connected);
210
+ flushWaitingMessages((t, d, c) => streamingTransport.send(t, d, c));
211
+ startWsRetry();
212
+ };
569
213
 
570
- /**
571
- * Set a value at a nested path in an object
572
- */
573
- function setValueAtPath(obj, path, value) {
574
- const parts = path.split('.')
575
- let current = obj
214
+ /**
215
+ * Handle streaming connection closed
216
+ */
217
+ streamingTransport.onClose = () => {
218
+ ready = false;
219
+ notifyConnectionChange(ConnectionState.Disconnected);
220
+ };
576
221
 
577
- for (let i = 0; i < parts.length - 1; i++) {
578
- current = current[parts[i]]
222
+ /**
223
+ * Handle streaming transport errors
224
+ * @param {Error} err - The error that occurred
225
+ */
226
+ streamingTransport.onError = (err) =>
227
+ console.error("🦍 Streaming error:", err);
579
228
  }
580
229
 
581
- current[parts[parts.length - 1]] = value
230
+ streamingTransport.connect();
582
231
  }
583
232
 
584
233
  /**
585
- * Clean up L-tagged keys (rename key<!L> to key)
234
+ * Start periodic WebSocket retry attempts
235
+ *
236
+ * When using HTTP streaming, periodically attempts to upgrade to WebSocket.
237
+ * This allows the connection to upgrade when network conditions improve.
238
+ *
239
+ * @private
586
240
  */
587
- function cleanLinkedKeys(obj) {
588
- if (obj === null || obj === undefined || typeof obj !== 'object') {
589
- return obj
590
- }
591
-
592
- if (Array.isArray(obj)) {
593
- return obj.map(cleanLinkedKeys)
594
- }
595
-
596
- const cleaned = {}
597
- for (const key of Object.keys(obj)) {
598
- if (key.endsWith('<!L>')) {
599
- const cleanKey = key.slice(0, -4)
600
- cleaned[cleanKey] = obj[key]
601
- } else {
602
- cleaned[key] = cleanLinkedKeys(obj[key])
241
+ function startWsRetry() {
242
+ if (
243
+ wsRetryTimer ||
244
+ currentTransport !== "polling" ||
245
+ configuredTransport === "polling"
246
+ )
247
+ return;
248
+ wsRetryTimer = setInterval(() => {
249
+ if (currentTransport !== "polling") {
250
+ clearInterval(wsRetryTimer);
251
+ wsRetryTimer = null;
252
+ return;
603
253
  }
604
- }
605
- return cleaned
254
+ tryWebSocket(true);
255
+ }, WS_RETRY_INTERVAL);
606
256
  }
607
257
 
608
258
  /**
609
- * Fetch binary resources and hydrate data object
259
+ * Attempt to establish a WebSocket connection
260
+ *
261
+ * @param {boolean} [isRetry=false] - Whether this is a retry attempt from HTTP streaming mode
262
+ * @private
263
+ *
264
+ * @description
265
+ * Connection flow:
266
+ * 1. Creates WebSocket to server's /api/ape endpoint
267
+ * 2. Sets up fallback timer (only on initial connection with auto transport)
268
+ * 3. On success: marks ready, flushes queued messages
269
+ * 4. On failure: falls back to HTTP streaming (if auto mode)
270
+ * 5. On close: schedules reconnection if auto-reconnect enabled
610
271
  */
611
- async function fetchLinkedResources(data, clientId) {
612
- const resources = findLinkedResources(data)
272
+ function tryWebSocket(isRetry = false) {
273
+ const ws = new WebSocket(getSocketUrl());
274
+ let fallbackTimer = null;
613
275
 
614
- if (resources.length === 0) {
615
- return data
276
+ // Set up fallback to HTTP streaming if WebSocket doesn't connect in time
277
+ if (!isRetry && configuredTransport === "auto") {
278
+ fallbackTimer = setTimeout(() => {
279
+ if (ws.readyState !== WebSocket.OPEN) {
280
+ ws.close();
281
+ switchToStreaming();
282
+ }
283
+ }, WS_FALLBACK_TIMEOUT);
616
284
  }
617
285
 
618
- console.log(`🦍 Fetching ${resources.length} binary resource(s)`)
619
-
620
- const cleanedData = cleanLinkedKeys(data)
621
-
622
- const hostname = window.location.hostname
623
- const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(hostname)
624
- const isHttps = window.location.protocol === "https:"
625
- const port = window.location.port || (isLocal ? 9010 : (isHttps ? 443 : 80))
626
- const protocol = isHttps ? "https" : "http"
627
- const portSuffix = (port !== 80 && port !== 443) ? `:${port}` : ""
628
- const baseUrl = `${protocol}://${hostname}${portSuffix}`
629
-
630
- await Promise.all(resources.map(async ({ path, hash }) => {
631
- try {
632
- const response = await fetch(`${baseUrl}/api/ape/data/${hash}`, {
633
- credentials: 'include',
634
- headers: {
635
- 'X-Ape-Client-Id': clientId || ''
636
- }
637
- })
286
+ /**
287
+ * Handle WebSocket connection opened
288
+ */
289
+ ws.onopen = () => {
290
+ if (fallbackTimer) clearTimeout(fallbackTimer);
638
291
 
639
- if (!response.ok) {
640
- throw new Error(`Failed to fetch binary resource: ${response.status}`)
292
+ // If retrying from polling mode, close the streaming transport
293
+ if (isRetry && currentTransport === "polling") {
294
+ if (streamingTransport) streamingTransport.close();
295
+ if (wsRetryTimer) {
296
+ clearInterval(wsRetryTimer);
297
+ wsRetryTimer = null;
641
298
  }
642
-
643
- const arrayBuffer = await response.arrayBuffer()
644
- setValueAtPath(cleanedData, path, arrayBuffer)
645
- } catch (err) {
646
- console.error(`🦍 Failed to fetch binary resource at ${path}:`, err)
647
- setValueAtPath(cleanedData, path, null)
648
299
  }
649
- }))
650
300
 
651
- return cleanedData
301
+ currentTransport = "websocket";
302
+ __socket = ws;
303
+ ready = true;
304
+
305
+ // Set up subscription send function and re-subscribe to all channels
306
+ setSendFn((msg) => ws.send(jss.stringify(msg)));
307
+ resubscribeAll();
308
+
309
+ notifyConnectionChange(ConnectionState.Connected);
310
+ flushWaitingMessages(wsSend);
311
+ };
312
+
313
+ /**
314
+ * Handle incoming WebSocket messages
315
+ * @param {MessageEvent} event - WebSocket message event
316
+ */
317
+ ws.onmessage = async (event) => {
318
+ const { err, type, queryId, data } = jss.parse(event.data);
319
+
320
+ // Check if this is a response to a pending request
321
+ if (queryId && waitingOn[queryId]) {
322
+ const hydratedData = await processIncomingData(data, err);
323
+ waitingOn[queryId](err, hydratedData);
324
+ delete waitingOn[queryId];
325
+ return;
326
+ }
327
+
328
+ // Otherwise dispatch as a broadcast/push message
329
+ const processed = await processIncomingData(data, err);
330
+ dispatchMessage(type, err, processed);
331
+ };
332
+
333
+ /**
334
+ * Handle WebSocket errors
335
+ * @param {Event} err - Error event
336
+ */
337
+ ws.onerror = (err) => {
338
+ if (fallbackTimer) clearTimeout(fallbackTimer);
339
+ // Fall back to streaming on initial connection failure
340
+ if (!isRetry && configuredTransport === "auto" && !ready)
341
+ switchToStreaming();
342
+ };
343
+
344
+ /**
345
+ * Handle WebSocket connection closed
346
+ */
347
+ ws.onclose = () => {
348
+ if (fallbackTimer) clearTimeout(fallbackTimer);
349
+ __socket = false;
350
+ ready = false;
351
+
352
+ // Only handle reconnection if we were using WebSocket transport
353
+ if (currentTransport === "websocket") {
354
+ notifyConnectionChange(ConnectionState.Disconnected);
355
+ setTimeout(() => reconnect && connectSocket(), 500);
356
+ }
357
+ };
652
358
  }
653
359
 
654
360
  /**
655
- * Attempt to establish connection with network pre-checks
361
+ * Attempt to establish a connection to the server
362
+ *
363
+ * This function orchestrates the connection process:
364
+ * 1. Checks if browser is online
365
+ * 2. Detects captive portals (hotel/airport WiFi login pages)
366
+ * 3. Initiates appropriate transport based on configuration
367
+ *
368
+ * @async
369
+ * @private
656
370
  */
657
371
  async function attemptConnection() {
658
- // Check if browser is online
659
- if (typeof navigator !== 'undefined' && !navigator.onLine) {
660
- notifyConnectionChange(ConnectionState.Offline)
661
- return
372
+ // Check browser online status first
373
+ if (typeof navigator !== "undefined" && !navigator.onLine) {
374
+ notifyConnectionChange(ConnectionState.Offline);
375
+ return;
662
376
  }
663
377
 
664
- // Perform captive portal check
665
- notifyConnectionChange(ConnectionState.Connecting)
666
- const pingResult = await checkCaptivePortal()
378
+ notifyConnectionChange(ConnectionState.Connecting);
667
379
 
668
- if (pingResult === 'walled') {
669
- notifyConnectionChange(ConnectionState.Walled)
670
- // Retry network check periodically
671
- scheduleNetworkRetry()
672
- return
380
+ // Check for captive portal
381
+ if ((await checkCaptivePortal()) === "walled") {
382
+ notifyConnectionChange(ConnectionState.Walled);
383
+ scheduleNetworkRetry(attemptConnection);
384
+ return;
673
385
  }
674
386
 
675
- // Network is good, proceed with socket connection
676
- proceedWithConnection()
387
+ // Start appropriate transport
388
+ configuredTransport === "polling" ? switchToStreaming() : tryWebSocket(false);
677
389
  }
678
390
 
679
391
  /**
680
- * Schedule a retry of network check (for walled/offline states)
392
+ * Create the sender function with current connection state
393
+ * @type {function(string, any): Promise<any>}
394
+ * @private
681
395
  */
682
- function scheduleNetworkRetry() {
683
- if (networkCheckTimer) return
684
- networkCheckTimer = setTimeout(() => {
685
- networkCheckTimer = null
686
- attemptConnection()
687
- }, WS_RETRY_INTERVAL)
688
- }
396
+ const sender = createSender(
397
+ () => ready,
398
+ () => wsSend,
399
+ aWaitingSend,
400
+ connectSocket,
401
+ );
689
402
 
690
403
  /**
691
- * Proceed with WebSocket/polling connection after network checks pass
404
+ * Initialize or retrieve the client connection
405
+ *
406
+ * This is the main entry point for establishing connections.
407
+ * Calling it multiple times returns the same client interface.
408
+ *
409
+ * @returns {ClientInterface} Client interface with sender, receivers, and state management
410
+ *
411
+ * @example
412
+ * const client = connectSocket()
413
+ *
414
+ * // Access proxied sender
415
+ * client.sender.myEndpoint({ data: 'value' })
416
+ *
417
+ * // Subscribe to messages
418
+ * client.setOnReceiver('eventType', handler)
419
+ *
420
+ * // Check current transport
421
+ * console.log(client.transport) // 'websocket' or 'polling'
692
422
  */
693
- function proceedWithConnection() {
694
- // Determine which transport to use
695
- if (configuredTransport === 'polling') {
696
- switchToStreaming()
697
- } else {
698
- // 'auto' or 'websocket' - try WebSocket first
699
- tryWebSocket(false)
700
- }
701
- }
702
-
703
423
  function connectSocket() {
704
- // Skip if already connected or connecting
705
- if (__socket && __socket.readyState !== WebSocket.CLOSED) {
706
- return buildClientInterface()
707
- }
708
- if (currentTransport === 'polling' && streamingTransport?.isConnected()) {
709
- return buildClientInterface()
710
- }
711
- if (connectionState === ConnectionState.Connecting) {
712
- return buildClientInterface()
713
- }
714
-
715
- // Start connection with network pre-checks
716
- attemptConnection()
717
-
718
- return buildClientInterface()
719
- }
720
-
721
- /**
722
- * Check if value is binary data (ArrayBuffer, typed array, or Blob)
723
- */
724
- function isBinaryData(value) {
725
- if (value === null || value === undefined) return false
726
- return value instanceof ArrayBuffer ||
727
- ArrayBuffer.isView(value) ||
728
- (typeof Blob !== 'undefined' && value instanceof Blob)
729
- }
730
-
731
- /**
732
- * Get binary type tag (A for ArrayBuffer, B for Blob)
733
- */
734
- function getBinaryTag(value) {
735
- if (typeof Blob !== 'undefined' && value instanceof Blob) return 'B'
736
- return 'A'
737
- }
738
-
739
- /**
740
- * Generate a simple hash for binary upload
741
- */
742
- function generateUploadHash(path) {
743
- let hash = 0
744
- for (let i = 0; i < path.length; i++) {
745
- const char = path.charCodeAt(i)
746
- hash = ((hash << 5) - hash) + char
747
- hash = hash & hash
748
- }
749
- return Math.abs(hash).toString(36)
750
- }
751
-
752
- /**
753
- * Find and extract binary data from payload
754
- * Returns { processedData, uploads: [{ path, hash, data, tag }] }
755
- */
756
- function processBinaryForUpload(data, path = '') {
757
- if (data === null || data === undefined) {
758
- return { processedData: data, uploads: [] }
759
- }
760
-
761
- if (isBinaryData(data)) {
762
- const tag = getBinaryTag(data)
763
- const hash = generateUploadHash(path || 'root')
764
- return {
765
- processedData: { [`__ape_upload__`]: hash },
766
- uploads: [{ path, hash, data, tag }]
767
- }
768
- }
769
-
770
- if (Array.isArray(data)) {
771
- const processedArray = []
772
- const allUploads = []
773
-
774
- for (let i = 0; i < data.length; i++) {
775
- const itemPath = path ? `${path}.${i}` : String(i)
776
- const { processedData, uploads } = processBinaryForUpload(data[i], itemPath)
777
- processedArray.push(processedData)
778
- allUploads.push(...uploads)
779
- }
780
-
781
- return { processedData: processedArray, uploads: allUploads }
782
- }
783
-
784
- if (typeof data === 'object') {
785
- const processedObj = {}
786
- const allUploads = []
787
-
788
- for (const key of Object.keys(data)) {
789
- const itemPath = path ? `${path}.${key}` : key
790
- const { processedData, uploads } = processBinaryForUpload(data[key], itemPath)
791
-
792
- // If this was binary data, mark the key with <!B> or <!A> tag
793
- if (uploads.length > 0 && processedData?.__ape_upload__) {
794
- const tag = uploads[uploads.length - 1].tag
795
- processedObj[`${key}<!${tag}>`] = processedData.__ape_upload__
796
- } else {
797
- processedObj[key] = processedData
798
- }
799
- allUploads.push(...uploads)
800
- }
801
-
802
- return { processedData: processedObj, uploads: allUploads }
803
- }
804
-
805
- return { processedData: data, uploads: [] }
806
- }
807
-
808
- /**
809
- * Find and extract binary data for SHARING (client-to-client)
810
- * Uses <!F> tag instead of <!A>/<!B>
811
- * Returns { processedData, shares: [{ path, hash, data }] }
812
- */
813
- function processBinaryForSharing(data, path = '') {
814
- if (data === null || data === undefined) {
815
- return { processedData: data, shares: [] }
816
- }
817
-
818
- if (isBinaryData(data)) {
819
- const hash = generateUploadHash(path || 'share')
820
- return {
821
- processedData: { [`__ape_share__`]: hash },
822
- shares: [{ path, hash, data }]
823
- }
824
- }
825
-
826
- if (Array.isArray(data)) {
827
- const processedArray = []
828
- const allShares = []
424
+ // Return existing interface if already connected
425
+ if (__socket && __socket.readyState !== WebSocket.CLOSED)
426
+ return buildClientInterface();
427
+ if (currentTransport === "polling" && streamingTransport?.isConnected())
428
+ return buildClientInterface();
829
429
 
830
- for (let i = 0; i < data.length; i++) {
831
- const itemPath = path ? `${path}.${i}` : String(i)
832
- const { processedData, shares } = processBinaryForSharing(data[i], itemPath)
833
- processedArray.push(processedData)
834
- allShares.push(...shares)
835
- }
836
-
837
- return { processedData: processedArray, shares: allShares }
838
- }
839
-
840
- if (typeof data === 'object') {
841
- const processedObj = {}
842
- const allShares = []
843
-
844
- for (const key of Object.keys(data)) {
845
- const itemPath = path ? `${path}.${key}` : key
846
- const { processedData, shares } = processBinaryForSharing(data[key], itemPath)
847
-
848
- // If this was binary data, mark the key with <!F> tag
849
- if (shares.length > 0 && processedData?.__ape_share__) {
850
- processedObj[`${key}<!F>`] = processedData.__ape_share__
851
- } else {
852
- processedObj[key] = processedData
853
- }
854
- allShares.push(...shares)
855
- }
856
-
857
- return { processedData: processedObj, shares: allShares }
858
- }
859
-
860
- return { processedData: data, shares: [] }
430
+ // Otherwise initiate connection
431
+ attemptConnection();
432
+ return buildClientInterface();
861
433
  }
862
434
 
863
435
  /**
864
- * Upload shared files via HTTP PUT
865
- * Uses different endpoint pattern for streaming files
436
+ * Build the public client interface object
437
+ *
438
+ * @returns {ClientInterface} The client interface
439
+ * @private
440
+ *
441
+ * @typedef {Object} ClientInterface
442
+ * @property {Proxy} sender - Proxied sender for calling server endpoints
443
+ * @property {function(string|function, function=): void} setOnReceiver - Register message handlers
444
+ * @property {function(function): function} onConnectionChange - Subscribe to connection state changes
445
+ * @property {'websocket'|'polling'|null} transport - Current transport type (read-only)
866
446
  */
867
- async function uploadSharedFiles(shares) {
868
- if (shares.length === 0) return
869
-
870
- // Build base URL
871
- const hostname = window.location.hostname
872
- const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(hostname)
873
- const isHttps = window.location.protocol === "https:"
874
- const port = window.location.port || (isLocal ? 9010 : (isHttps ? 443 : 80))
875
- const protocol = isHttps ? "https" : "http"
876
- const portSuffix = (port !== 80 && port !== 443) ? `:${port}` : ""
877
- const baseUrl = `${protocol}://${hostname}${portSuffix}`
878
-
879
- console.log(`🦍 Uploading ${shares.length} shared file(s)`)
880
-
881
- await Promise.all(shares.map(async ({ hash, data }) => {
882
- try {
883
- // For shared files, use upload pattern with hash as both queryId and pathHash
884
- const response = await fetch(`${baseUrl}/api/ape/data/_share/${hash}`, {
885
- method: 'PUT',
886
- credentials: 'include',
887
- headers: {
888
- 'Content-Type': 'application/octet-stream'
889
- },
890
- body: data
891
- })
447
+ function buildClientInterface() {
448
+ return {
449
+ /**
450
+ * Proxied sender object for calling server endpoints
451
+ *
452
+ * Properties accessed on this object are converted to API paths.
453
+ *
454
+ * @example
455
+ * // Calls /chat endpoint
456
+ * sender.chat({ message: 'Hi' })
457
+ *
458
+ * // Calls /users/123 endpoint
459
+ * sender.users('/123', { action: 'get' })
460
+ *
461
+ * @type {Proxy}
462
+ */
463
+ sender: wrap(sender),
892
464
 
893
- if (!response.ok) {
894
- throw new Error(`Shared upload failed: ${response.status}`)
895
- }
896
- } catch (err) {
897
- console.error(`🦍 Failed to upload shared file ${hash}:`, err)
898
- throw err
899
- }
900
- }))
465
+ /**
466
+ * Register a message receiver/handler
467
+ * @see {@link module:client/connection/messageHandler.setOnReceiver}
468
+ */
469
+ setOnReceiver,
470
+
471
+ /**
472
+ * Subscribe to connection state changes
473
+ * @type {function(function(ConnectionStateValue): void): function(): void}
474
+ */
475
+ onConnectionChange,
476
+
477
+ /**
478
+ * Current transport type
479
+ * @type {'websocket'|'polling'|null}
480
+ * @readonly
481
+ */
482
+ get transport() {
483
+ return currentTransport;
484
+ },
485
+ };
901
486
  }
902
487
 
903
488
  /**
904
- * Upload binary data via HTTP PUT
489
+ * Enable automatic reconnection on connection loss
490
+ *
491
+ * When enabled, the client will automatically attempt to reconnect
492
+ * when the WebSocket connection is closed unexpectedly.
493
+ *
494
+ * @static
495
+ * @memberof connectSocket
496
+ *
497
+ * @example
498
+ * connectSocket.autoReconnect()
905
499
  */
906
- async function uploadBinaryData(queryId, uploads) {
907
- if (uploads.length === 0) return
908
-
909
- // Build base URL
910
- const hostname = window.location.hostname
911
- const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(hostname)
912
- const isHttps = window.location.protocol === "https:"
913
- const port = window.location.port || (isLocal ? 9010 : (isHttps ? 443 : 80))
914
- const protocol = isHttps ? "https" : "http"
915
- const portSuffix = (port !== 80 && port !== 443) ? `:${port}` : ""
916
- const baseUrl = `${protocol}://${hostname}${portSuffix}`
917
-
918
- console.log(`🦍 Uploading ${uploads.length} binary file(s)`)
919
-
920
- await Promise.all(uploads.map(async ({ hash, data }) => {
921
- try {
922
- const response = await fetch(`${baseUrl}/api/ape/data/${queryId}/${hash}`, {
923
- method: 'PUT',
924
- credentials: 'include',
925
- headers: {
926
- 'Content-Type': 'application/octet-stream'
927
- },
928
- body: data
929
- })
930
-
931
- if (!response.ok) {
932
- throw new Error(`Upload failed: ${response.status}`)
933
- }
934
- } catch (err) {
935
- console.error(`🦍 Failed to upload binary at ${hash}:`, err)
936
- throw err
937
- }
938
- }))
939
- }
940
-
941
- wsSend = function (type, data, createdAt, dirctCall) {
942
- let rej, promiseIsLive = false;
943
- const timeLetForReqToBeMade = (createdAt + totalRequestTimeout) - Date.now()
944
-
945
- const timer = setTimeout(() => {
946
- if (promiseIsLive) {
947
- rej(new Error("Request Timedout for :" + type))
948
- }
949
- }, timeLetForReqToBeMade);
950
-
951
- // Process binary data for upload
952
- const { processedData, uploads } = processBinaryForUpload(data)
953
-
954
- const payload = {
955
- type,
956
- data: processedData,
957
- //referer:window.location.href,
958
- createdAt: new Date(createdAt),
959
- requestedAt: dirctCall ? undefined
960
- : new Date()
961
- }
962
- const message = jss.stringify(payload)
963
- const queryId = messageHash(message);
964
-
965
- const replyPromise = new Promise((resolve, reject) => {
966
- rej = reject
967
- waitingOn[queryId] = (err, result) => {
968
- clearTimeout(timer)
969
- replyPromise.then = next.bind(replyPromise)
970
- if (err) {
971
- reject(err)
972
- } else {
973
- resolve(result)
974
- }
975
- }
976
- __socket.send(message);
977
-
978
- // Upload binary data after sending WS message
979
- if (uploads.length > 0) {
980
- uploadBinaryData(queryId, uploads).catch(err => {
981
- console.error('🦍 Binary upload failed:', err)
982
- // The server will timeout waiting for the upload
983
- })
984
- }
985
- });
986
- const next = replyPromise.then;
987
- replyPromise.then = worker => {
988
- promiseIsLive = true;
989
- replyPromise.then = next.bind(replyPromise)
990
- replyPromise.catch = err.bind(replyPromise)
991
- return next.call(replyPromise, worker)
992
- }
993
- const err = replyPromise.catch;
994
- replyPromise.catch = worker => {
995
- promiseIsLive = true;
996
- replyPromise.catch = err.bind(replyPromise)
997
- replyPromise.then = next.bind(replyPromise)
998
- return err.call(replyPromise, worker)
999
- }
1000
- return replyPromise
1001
- } // END wsSend
1002
-
1003
-
1004
- const sender = (type, data) => {
1005
- if ("string" !== typeof type) {
1006
- throw new Error("Missing Path vaule")
1007
- }
1008
-
1009
- const createdAt = Date.now()
1010
-
1011
- if (ready) {
1012
- return wsSend(type, data, createdAt, true)
1013
- }
1014
-
1015
- const timeLetForReqToBeMade = (createdAt + connectTimeout) - Date.now() // 5sec for reconnect
1016
-
1017
- const timer = setTimeout(() => {
1018
- const errMessage = "Request not sent for :" + type
1019
- if (payload.waiting) {
1020
- payload.reject(new Error(errMessage))
1021
- } else {
1022
- throw new Error(errMessage)
1023
- }
1024
- }, timeLetForReqToBeMade);
1025
-
1026
- const payload = { type, data, resolve: undefined, reject: undefined, waiting: false, createdAt, timer };
1027
- const waitingOnOpen = new Promise((res, rej) => { payload.resolve = res; payload.reject = rej; })
1028
-
1029
- const waitingOnOpenThen = waitingOnOpen.then;
1030
- const waitingOnOpenCatch = waitingOnOpen.catch;
1031
- waitingOnOpen.then = worker => {
1032
- payload.waiting = true;
1033
- waitingOnOpen.then = waitingOnOpenThen.bind(waitingOnOpen)
1034
- waitingOnOpen.catch = waitingOnOpenCatch.bind(waitingOnOpen)
1035
- return waitingOnOpenThen.call(waitingOnOpen, worker)
1036
- }
1037
- waitingOnOpen.catch = worker => {
1038
- payload.waiting = true;
1039
- waitingOnOpen.catch = waitingOnOpenCatch.bind(waitingOnOpen)
1040
- waitingOnOpen.then = waitingOnOpenThen.bind(waitingOnOpen)
1041
- return waitingOnOpenCatch.call(waitingOnOpen, worker)
1042
- }
1043
-
1044
- aWaitingSend.push(payload)
1045
- if (!__socket) {
1046
- connectSocket()
1047
- }
1048
-
1049
- return waitingOnOpen
1050
- } // END sender
500
+ connectSocket.autoReconnect = () => (reconnect = true);
1051
501
 
1052
502
  /**
1053
- * Build the client interface object
503
+ * Connection state enum reference
504
+ *
505
+ * @static
506
+ * @memberof connectSocket
507
+ * @type {typeof ConnectionState}
508
+ *
509
+ * @example
510
+ * client.onConnectionChange((state) => {
511
+ * if (state === connectSocket.ConnectionState.Connected) {
512
+ * console.log('Connected!')
513
+ * }
514
+ * })
1054
515
  */
1055
- function buildClientInterface() {
1056
- return {
1057
- sender: wrap(sender),
1058
- setOnReceiver: (onTypeStFn, handlerFn) => {
1059
- if ("string" === typeof onTypeStFn) {
1060
- // Replace handler for this type (prevents duplicates in React StrictMode)
1061
- ofTypesOb[onTypeStFn] = [handlerFn]
1062
- } else {
1063
- // For general receivers, prevent duplicates by checking
1064
- if (!receiverArray.includes(onTypeStFn)) {
1065
- receiverArray.push(onTypeStFn)
1066
- }
1067
- }
1068
- },
1069
- onConnectionChange: (handler) => {
1070
- connectionChangeListeners.push(handler)
1071
- // Immediately call with current state
1072
- handler(connectionState)
1073
- // Return unsubscribe function
1074
- return () => {
1075
- const idx = connectionChangeListeners.indexOf(handler)
1076
- if (idx > -1) connectionChangeListeners.splice(idx, 1)
1077
- }
1078
- },
1079
- // Expose current transport type (read-only)
1080
- get transport() { return currentTransport }
1081
- }
1082
- }
1083
-
1084
- connectSocket.autoReconnect = () => reconnect = true
1085
- connectSocket.ConnectionState = ConnectionState
1086
- connect = connectSocket
516
+ connectSocket.ConnectionState = ConnectionState;
1087
517
 
1088
- export default connect;
518
+ export default connectSocket;
1089
519
  export { ConnectionState };