api-ape 2.2.2 → 2.3.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/README.md CHANGED
@@ -116,6 +116,57 @@ api.onConnectionChange((state) => {
116
116
  * **HTTP streaming fallback** — Automatically falls back to long polling when WebSockets are blocked
117
117
  * **JJS Encoding** — Extended JSON supporting Date, RegExp, Error, Set, Map, undefined, and circular refs
118
118
  * **Connection lifecycle hooks** — Customize behavior on connect, receive, send, error, and disconnect
119
+ * **🌲 Forest** — Distributed mesh for horizontal scaling across multiple servers
120
+
121
+ ---
122
+
123
+ ## 🌲 Forest: Distributed Mesh
124
+
125
+ **Forest** enables horizontal scaling by coordinating multiple api-ape servers through a shared database. Messages are routed directly to the server hosting the destination client — no broadcast spam.
126
+
127
+ ```js
128
+ import { createClient } from 'redis';
129
+ const redis = createClient();
130
+ await redis.connect();
131
+
132
+ // Join the mesh — that's it!
133
+ ape.joinVia(redis);
134
+ ```
135
+
136
+ ### Supported Backends
137
+
138
+ | Backend | Push Mechanism | Best For |
139
+ |---------|---------------|----------|
140
+ | **Redis** | PUB/SUB | Most deployments |
141
+ | **MongoDB** | Change Streams | Mongo-native stacks |
142
+ | **PostgreSQL** | LISTEN/NOTIFY | SQL shops |
143
+ | **Supabase** | Realtime | Supabase users |
144
+ | **Firebase** | Native push | Serverless/edge |
145
+
146
+ ### How It Works
147
+
148
+ ```
149
+ ┌─────────────┐ ┌─────────────┐
150
+ │ Server A │ │ Server B │
151
+ │ client-1 │ │ client-2 │
152
+ └──────┬──────┘ └──────▲──────┘
153
+ │ │
154
+ │ 1. sendTo("client-2") │
155
+ │ → lookup: client-2 → srv-B │
156
+ │ │
157
+ │ 2. channels.push("srv-B", msg) │
158
+ └──────────┬───────────────────────┘
159
+
160
+ ┌──────▼──────┐
161
+ │ Database │
162
+ │ (message │
163
+ │ bus) │
164
+ └─────────────┘
165
+ ```
166
+
167
+ APE creates its own namespaced keys/tables (`ape:*` or `ape_*`). No schema conflicts with your data.
168
+
169
+ 👉 **[See detailed Forest documentation](server/README.md#forest-distributed-mesh)**
119
170
 
120
171
  ---
121
172
 
@@ -131,7 +182,7 @@ Initialize api-ape on a Node.js HTTP/HTTPS server.
131
182
  |--------|------|-------------|
132
183
  | `server` | `http.Server` | Node.js HTTP or HTTPS server instance |
133
184
  | `where` | `string` | Directory containing controller files (default: `'api'`) |
134
- | `onConnent` | `function` | Connection lifecycle hook (see [Connection Lifecycle](#connection-lifecycle)) |
185
+ | `onConnect` | `function` | Connection lifecycle hook (see [Connection Lifecycle](#connection-lifecycle)) |
135
186
 
136
187
  #### Controller Context (`this`)
137
188
 
@@ -141,14 +192,24 @@ Inside controller functions, `this` provides:
141
192
  |----------|-------------|
142
193
  | `this.broadcast(type, data)` | Send to **ALL** connected clients |
143
194
  | `this.broadcastOthers(type, data)` | Send to all **EXCEPT** the caller |
144
- | `this.online()` | Get count of connected clients |
145
- | `this.getClients()` | Get array of connected clientIds |
146
195
  | `this.clientId` | Unique ID of the calling client (generated by api-ape) |
147
196
  | `this.sessionId` | Session ID from cookie (set by outer framework, may be `null`) |
148
197
  | `this.req` | Original HTTP request |
149
198
  | `this.socket` | WebSocket instance |
150
199
  | `this.agent` | Parsed user-agent (browser, OS, device) |
151
200
 
201
+ #### `ape.clients`
202
+
203
+ A read-only Map of connected clients. Each client provides:
204
+
205
+ | Property | Description |
206
+ |----------|-------------|
207
+ | `clientId` | Unique client identifier |
208
+ | `sessionId` | Session ID from cookie (may be `null`) |
209
+ | `embed` | Embedded values from onConnect |
210
+ | `agent` | Parsed user-agent (browser, OS, device) |
211
+ | `sendTo(type, data)` | Send a message to this specific client |
212
+
152
213
  ### Client
153
214
 
154
215
  #### `api.<path>.<method>(...args)`
@@ -178,13 +239,12 @@ api.on('notification', ({ data, err, type }) => {
178
239
  })
179
240
  ```
180
241
 
181
- #### `api.getTransport()`
242
+ #### `api.transport`
182
243
 
183
- Get the currently active transport type.
244
+ Get the currently active transport type. This is a read-only property.
184
245
 
185
246
  ```js
186
- const transport = api.getTransport()
187
- console.log(transport) // 'websocket' | 'polling' | null
247
+ console.log(api.transport) // 'websocket' | 'polling' | null
188
248
  ```
189
249
 
190
250
  #### `api.onConnectionChange(handler)`
@@ -229,7 +289,7 @@ Customize behavior per connection:
229
289
  ```js
230
290
  ape(server, {
231
291
  where: 'api',
232
- onConnent(socket, req, clientId) {
292
+ onConnect(socket, req, clientId) {
233
293
  return {
234
294
  // Embed values into `this` for all controllers
235
295
  embed: {
@@ -249,7 +309,7 @@ ape(server, {
249
309
  },
250
310
 
251
311
  onError: (errStr) => console.error(errStr),
252
- onDisconnent: () => console.log('Client left')
312
+ onDisconnect: () => console.log('Client left')
253
313
  }
254
314
  }
255
315
  })
@@ -281,16 +341,27 @@ module.exports = function(announcement) {
281
341
  }
282
342
  ```
283
343
 
284
- ### Get Online Count
344
+ ### Using ape.clients
285
345
 
286
346
  ```js
287
- // api/stats.js
288
- module.exports = function() {
289
- return {
290
- online: this.online(),
291
- clients: this.getClients()
292
- }
347
+ const ape = require('api-ape')
348
+
349
+ // Get online count
350
+ console.log('Online:', ape.clients.size)
351
+
352
+ // Get all client IDs
353
+ const clientIds = Array.from(ape.clients.keys())
354
+
355
+ // Send to a specific client
356
+ const client = ape.clients.get(someClientId)
357
+ if (client) {
358
+ client.sendTo('notification', { message: 'Hello!' })
293
359
  }
360
+
361
+ // Iterate all clients
362
+ ape.clients.forEach((client, clientId) => {
363
+ console.log(`Client ${clientId}:`, client.sessionId, client.agent.browser.name)
364
+ })
294
365
  ```
295
366
 
296
367
  ### Access Request Data
@@ -454,7 +525,7 @@ This prevents malicious sites from making requests to your api-ape server while
454
525
  ### Security Considerations
455
526
 
456
527
  * Validate all input in controllers
457
- * Use authentication/authorization in `onConnent` hooks
528
+ * Use authentication/authorization in `onConnect` hooks
458
529
  * Sanitize data before broadcasting
459
530
  * Keep dependencies up to date
460
531
 
package/client/browser.js CHANGED
@@ -1,25 +1,25 @@
1
1
  import connectSocket from './connectSocket.js'
2
2
 
3
- const { sender, setOnReciver, onConnectionChange, getTransport } = connectSocket()
3
+ const client = connectSocket()
4
4
  connectSocket.autoReconnect()
5
5
 
6
6
  // Global API - use defineProperty to bypass Proxy interception
7
- window.api = sender
7
+ window.api = client.sender
8
8
  Object.defineProperty(window.api, 'on', {
9
- value: setOnReciver,
9
+ value: client.setOnReceiver,
10
10
  writable: false,
11
11
  enumerable: false,
12
12
  configurable: false
13
13
  })
14
14
  Object.defineProperty(window.api, 'onConnectionChange', {
15
- value: onConnectionChange,
15
+ value: client.onConnectionChange,
16
16
  writable: false,
17
17
  enumerable: false,
18
18
  configurable: false
19
19
  })
20
- Object.defineProperty(window.api, 'getTransport', {
21
- value: getTransport,
22
- writable: false,
20
+ // Read-only transport property - only ape can change this internally
21
+ Object.defineProperty(window.api, 'transport', {
22
+ get: () => client.transport,
23
23
  enumerable: false,
24
24
  configurable: false
25
25
  })
@@ -160,13 +160,13 @@ function getSocketUrl() {
160
160
  }
161
161
 
162
162
  let reconnect = false
163
- const connentTimeout = 5000
163
+ const connectTimeout = 5000
164
164
  const totalRequestTimeout = 10000
165
165
  //const location = window.location
166
166
 
167
167
  const joinKey = "/"
168
168
  // Properties accessed directly on `ape` that should NOT be intercepted
169
- const reservedKeys = new Set(['on', 'onConnectionChange', 'getTransport'])
169
+ const reservedKeys = new Set(['on', 'onConnectionChange', 'transport'])
170
170
  const handler = {
171
171
  get(fn, key) {
172
172
  // Skip proxy interception for reserved keys - return actual property
@@ -193,10 +193,9 @@ function wrap(api) {
193
193
 
194
194
  let __socket = false, ready = false, wsSend = false;
195
195
  const waitingOn = {};
196
- const reciverOn = [];
197
196
 
198
197
  let aWaitingSend = []
199
- const reciverOnAr = [];
198
+ const receiverArray = [];
200
199
  const ofTypesOb = {};
201
200
 
202
201
  /**
@@ -213,12 +212,23 @@ function switchToStreaming() {
213
212
  streamingTransport.onMessage = async (msg) => {
214
213
  const { err, type, data } = msg
215
214
 
215
+ // Process linked resources and shared files
216
+ let processedData = data
217
+ if (data && !err) {
218
+ try {
219
+ processedData = await fetchLinkedResources(data)
220
+ processedData = await fetchSharedFiles(processedData)
221
+ } catch (fetchErr) {
222
+ console.error(`🦍 Failed to hydrate streaming data:`, fetchErr)
223
+ }
224
+ }
225
+
216
226
  // Dispatch to type-specific handlers
217
227
  if (ofTypesOb[type]) {
218
- ofTypesOb[type].forEach(worker => worker({ err, type, data }))
228
+ ofTypesOb[type].forEach(worker => worker({ err, type, data: processedData }))
219
229
  }
220
230
  // Dispatch to general handlers
221
- reciverOnAr.forEach(worker => worker({ err, type, data }))
231
+ receiverArray.forEach(worker => worker({ err, type, data: processedData }))
222
232
  }
223
233
 
224
234
  streamingTransport.onOpen = () => {
@@ -227,11 +237,11 @@ function switchToStreaming() {
227
237
  console.log('🦍 HTTP streaming connected')
228
238
 
229
239
  // Flush waiting messages
230
- aWaitingSend.forEach(({ type, data, next, err, waiting, createdAt, timer }) => {
240
+ aWaitingSend.forEach(({ type, data, resolve, reject, waiting, createdAt, timer }) => {
231
241
  clearTimeout(timer)
232
242
  const resultPromise = streamingSend(type, data, createdAt)
233
243
  if (waiting) {
234
- resultPromise.then(next).catch(err)
244
+ resultPromise.then(resolve).catch(reject)
235
245
  }
236
246
  })
237
247
  aWaitingSend = []
@@ -319,11 +329,11 @@ function tryWebSocket(isRetry = false) {
319
329
  ready = true
320
330
  notifyConnectionChange(ConnectionState.Connected)
321
331
 
322
- aWaitingSend.forEach(({ type, data, next, err, waiting, createdAt, timer }) => {
332
+ aWaitingSend.forEach(({ type, data, resolve, reject, waiting, createdAt, timer }) => {
323
333
  clearTimeout(timer)
324
334
  const resultPromise = wsSend(type, data, createdAt)
325
335
  if (waiting) {
326
- resultPromise.then(next).catch(err)
336
+ resultPromise.then(resolve).catch(reject)
327
337
  }
328
338
  })
329
339
  aWaitingSend = []
@@ -338,7 +348,8 @@ function tryWebSocket(isRetry = false) {
338
348
  // Check for linked resources and fetch them before resolving
339
349
  if (data && !err) {
340
350
  try {
341
- const hydratedData = await fetchLinkedResources(data)
351
+ let hydratedData = await fetchLinkedResources(data)
352
+ hydratedData = await fetchSharedFiles(hydratedData)
342
353
  waitingOn[queryId](err, hydratedData)
343
354
  } catch (fetchErr) {
344
355
  waitingOn[queryId](fetchErr, null)
@@ -353,11 +364,12 @@ function tryWebSocket(isRetry = false) {
353
364
  return
354
365
  }
355
366
 
356
- // Only messages WITHOUT queryId go to setOnReciver
367
+ // Only messages WITHOUT queryId go to setOnReceiver
357
368
  let processedData = data
358
369
  if (data && !err) {
359
370
  try {
360
371
  processedData = await fetchLinkedResources(data)
372
+ processedData = await fetchSharedFiles(processedData)
361
373
  } catch (fetchErr) {
362
374
  console.error(`🦍 Failed to hydrate broadcast data:`, fetchErr)
363
375
  }
@@ -366,7 +378,7 @@ function tryWebSocket(isRetry = false) {
366
378
  if (ofTypesOb[type]) {
367
379
  ofTypesOb[type].forEach(worker => worker({ err, type, data: processedData }))
368
380
  }
369
- reciverOnAr.forEach(worker => worker({ err, type, data: processedData }))
381
+ receiverArray.forEach(worker => worker({ err, type, data: processedData }))
370
382
  }
371
383
 
372
384
  ws.onerror = function (err) {
@@ -429,6 +441,134 @@ function findLinkedResources(obj, path = '') {
429
441
  return resources
430
442
  }
431
443
 
444
+ /**
445
+ * Find all F-tagged (shared file) properties in data
446
+ * Returns array of { path, hash, originalKey }
447
+ */
448
+ function findFileTags(obj, path = '') {
449
+ const files = []
450
+
451
+ if (obj === null || obj === undefined || typeof obj !== 'object') {
452
+ return files
453
+ }
454
+
455
+ if (Array.isArray(obj)) {
456
+ for (let i = 0; i < obj.length; i++) {
457
+ files.push(...findFileTags(obj[i], path ? `${path}.${i}` : String(i)))
458
+ }
459
+ return files
460
+ }
461
+
462
+ for (const key of Object.keys(obj)) {
463
+ // Check for F-tag in key (client-to-client shared file marker)
464
+ if (key.endsWith('<!F>')) {
465
+ const cleanKey = key.slice(0, -4)
466
+ const hash = obj[key]
467
+ files.push({
468
+ path: path ? `${path}.${cleanKey}` : cleanKey,
469
+ hash,
470
+ originalKey: key
471
+ })
472
+ } else {
473
+ files.push(...findFileTags(obj[key], path ? `${path}.${key}` : key))
474
+ }
475
+ }
476
+
477
+ return files
478
+ }
479
+
480
+ /**
481
+ * Clean up F-tagged keys (rename key<!F> to key)
482
+ */
483
+ function cleanFileTags(obj) {
484
+ if (obj === null || obj === undefined || typeof obj !== 'object') {
485
+ return obj
486
+ }
487
+
488
+ if (Array.isArray(obj)) {
489
+ return obj.map(cleanFileTags)
490
+ }
491
+
492
+ const cleaned = {}
493
+ for (const key of Object.keys(obj)) {
494
+ if (key.endsWith('<!F>')) {
495
+ const cleanKey = key.slice(0, -4)
496
+ cleaned[cleanKey] = obj[key]
497
+ } else {
498
+ cleaned[key] = cleanFileTags(obj[key])
499
+ }
500
+ }
501
+ return cleaned
502
+ }
503
+
504
+ /**
505
+ * Fetch shared files (client-to-client transfers)
506
+ * Retries if upload is still in progress
507
+ */
508
+ async function fetchSharedFiles(data, maxRetries = 5) {
509
+ const files = findFileTags(data)
510
+
511
+ if (files.length === 0) {
512
+ return data
513
+ }
514
+
515
+ console.log(`🦍 Fetching ${files.length} shared file(s)`)
516
+
517
+ const cleanedData = cleanFileTags(data)
518
+
519
+ const hostname = window.location.hostname
520
+ const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(hostname)
521
+ const isHttps = window.location.protocol === "https:"
522
+ const port = isLocal ? 9010 : (window.location.port || (isHttps ? 443 : 80))
523
+ const protocol = isHttps ? "https" : "http"
524
+ const portSuffix = (isLocal || (port !== 80 && port !== 443)) ? `:${port}` : ""
525
+ const baseUrl = `${protocol}://${hostname}${portSuffix}`
526
+
527
+ await Promise.all(files.map(async ({ path, hash }) => {
528
+ let retries = 0
529
+ let backoff = 100 // Start with 100ms
530
+
531
+ while (retries < maxRetries) {
532
+ try {
533
+ const response = await fetch(`${baseUrl}/api/ape/data/${hash}`, {
534
+ credentials: 'include'
535
+ })
536
+
537
+ if (!response.ok) {
538
+ // 404 might mean file not uploaded yet, retry
539
+ if (response.status === 404 && retries < maxRetries - 1) {
540
+ retries++
541
+ await new Promise(r => setTimeout(r, backoff))
542
+ backoff *= 2 // Exponential backoff
543
+ continue
544
+ }
545
+ throw new Error(`Failed to fetch shared file: ${response.status}`)
546
+ }
547
+
548
+ const arrayBuffer = await response.arrayBuffer()
549
+ setValueAtPath(cleanedData, path, arrayBuffer)
550
+
551
+ // Check if upload is still in progress
552
+ const isComplete = response.headers.get('X-Ape-Complete') === '1'
553
+ if (!isComplete) {
554
+ console.log(`🦍 Shared file ${hash} still uploading (${response.headers.get('X-Ape-Total-Received') || '?'} bytes)`)
555
+ }
556
+ break
557
+ } catch (err) {
558
+ if (retries >= maxRetries - 1) {
559
+ console.error(`🦍 Failed to fetch shared file at ${path}:`, err)
560
+ setValueAtPath(cleanedData, path, null)
561
+ }
562
+ retries++
563
+ await new Promise(r => setTimeout(r, backoff))
564
+ backoff *= 2
565
+ }
566
+ }
567
+ }))
568
+
569
+ return cleanedData
570
+ }
571
+
432
572
  /**
433
573
  * Set a value at a nested path in an object
434
574
  */
@@ -667,6 +807,101 @@ function processBinaryForUpload(data, path = '') {
667
807
  return { processedData: data, uploads: [] }
668
808
  }
669
809
 
810
+ /**
811
+ * Find and extract binary data for SHARING (client-to-client)
812
+ * Uses <!F> tag instead of <!A>/<!B>
813
+ * Returns { processedData, shares: [{ path, hash, data }] }
814
+ */
815
+ function processBinaryForSharing(data, path = '') {
816
+ if (data === null || data === undefined) {
817
+ return { processedData: data, shares: [] }
818
+ }
819
+
820
+ if (isBinaryData(data)) {
821
+ const hash = generateUploadHash(path || 'share')
822
+ return {
823
+ processedData: { [`__ape_share__`]: hash },
824
+ shares: [{ path, hash, data }]
825
+ }
826
+ }
827
+
828
+ if (Array.isArray(data)) {
829
+ const processedArray = []
830
+ const allShares = []
831
+
832
+ for (let i = 0; i < data.length; i++) {
833
+ const itemPath = path ? `${path}.${i}` : String(i)
834
+ const { processedData, shares } = processBinaryForSharing(data[i], itemPath)
835
+ processedArray.push(processedData)
836
+ allShares.push(...shares)
837
+ }
838
+
839
+ return { processedData: processedArray, shares: allShares }
840
+ }
841
+
842
+ if (typeof data === 'object') {
843
+ const processedObj = {}
844
+ const allShares = []
845
+
846
+ for (const key of Object.keys(data)) {
847
+ const itemPath = path ? `${path}.${key}` : key
848
+ const { processedData, shares } = processBinaryForSharing(data[key], itemPath)
849
+
850
+ // If this was binary data, mark the key with <!F> tag
851
+ if (shares.length > 0 && processedData?.__ape_share__) {
852
+ processedObj[`${key}<!F>`] = processedData.__ape_share__
853
+ } else {
854
+ processedObj[key] = processedData
855
+ }
856
+ allShares.push(...shares)
857
+ }
858
+
859
+ return { processedData: processedObj, shares: allShares }
860
+ }
861
+
862
+ return { processedData: data, shares: [] }
863
+ }
864
+
865
+ /**
866
+ * Upload shared files via HTTP PUT
867
+ * Uses different endpoint pattern for streaming files
868
+ */
869
+ async function uploadSharedFiles(shares) {
870
+ if (shares.length === 0) return
871
+
872
+ // Build base URL
873
+ const hostname = window.location.hostname
874
+ const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(hostname)
875
+ const isHttps = window.location.protocol === "https:"
876
+ const port = isLocal ? 9010 : (window.location.port || (isHttps ? 443 : 80))
877
+ const protocol = isHttps ? "https" : "http"
878
+ const portSuffix = (isLocal || (port !== 80 && port !== 443)) ? `:${port}` : ""
879
+ const baseUrl = `${protocol}://${hostname}${portSuffix}`
880
+
881
+ console.log(`🦍 Uploading ${shares.length} shared file(s)`)
882
+
883
+ await Promise.all(shares.map(async ({ hash, data }) => {
884
+ try {
885
+ // For shared files, use upload pattern with hash as both queryId and pathHash
886
+ const response = await fetch(`${baseUrl}/api/ape/data/_share/${hash}`, {
887
+ method: 'PUT',
888
+ credentials: 'include',
889
+ headers: {
890
+ 'Content-Type': 'application/octet-stream'
891
+ },
892
+ body: data
893
+ })
894
+
895
+ if (!response.ok) {
896
+ throw new Error(`Shared upload failed: ${response.status}`)
897
+ }
898
+ } catch (err) {
899
+ console.error(`🦍 Failed to upload shared file ${hash}:`, err)
900
+ throw err
901
+ }
902
+ }))
903
+ }
904
+
670
905
  /**
671
906
  * Upload binary data via HTTP PUT
672
907
  */
@@ -779,19 +1014,19 @@ const sender = (type, data) => {
779
1014
  return wsSend(type, data, createdAt, true)
780
1015
  }
781
1016
 
782
- const timeLetForReqToBeMade = (createdAt + connentTimeout) - Date.now() // 5sec for reconnent
1017
+ const timeLetForReqToBeMade = (createdAt + connectTimeout) - Date.now() // 5sec for reconnect
783
1018
 
784
1019
  const timer = setTimeout(() => {
785
1020
  const errMessage = "Request not sent for :" + type
786
1021
  if (payload.waiting) {
787
- payload.err(new Error(errMessage))
1022
+ payload.reject(new Error(errMessage))
788
1023
  } else {
789
1024
  throw new Error(errMessage)
790
1025
  }
791
1026
  }, timeLetForReqToBeMade);
792
1027
 
793
- const payload = { type, data, next: undefined, err: undefined, waiting: false, createdAt, timer };
794
- const waitingOnOpen = new Promise((res, er) => { payload.next = res; payload.err = er; })
1028
+ const payload = { type, data, resolve: undefined, reject: undefined, waiting: false, createdAt, timer };
1029
+ const waitingOnOpen = new Promise((res, rej) => { payload.resolve = res; payload.reject = rej; })
795
1030
 
796
1031
  const waitingOnOpenThen = waitingOnOpen.then;
797
1032
  const waitingOnOpenCatch = waitingOnOpen.catch;
@@ -822,14 +1057,14 @@ const sender = (type, data) => {
822
1057
  function buildClientInterface() {
823
1058
  return {
824
1059
  sender: wrap(sender),
825
- setOnReciver: (onTypeStFn, handlerFn) => {
1060
+ setOnReceiver: (onTypeStFn, handlerFn) => {
826
1061
  if ("string" === typeof onTypeStFn) {
827
1062
  // Replace handler for this type (prevents duplicates in React StrictMode)
828
1063
  ofTypesOb[onTypeStFn] = [handlerFn]
829
1064
  } else {
830
1065
  // For general receivers, prevent duplicates by checking
831
- if (!reciverOnAr.includes(onTypeStFn)) {
832
- reciverOnAr.push(onTypeStFn)
1066
+ if (!receiverArray.includes(onTypeStFn)) {
1067
+ receiverArray.push(onTypeStFn)
833
1068
  }
834
1069
  }
835
1070
  },
@@ -843,8 +1078,8 @@ function buildClientInterface() {
843
1078
  if (idx > -1) connectionChangeListeners.splice(idx, 1)
844
1079
  }
845
1080
  },
846
- // Expose current transport type
847
- getTransport: () => currentTransport
1081
+ // Expose current transport type (read-only)
1082
+ get transport() { return currentTransport }
848
1083
  }
849
1084
  }
850
1085
 
package/client/index.js CHANGED
@@ -55,7 +55,7 @@ function getClient() {
55
55
 
56
56
  // Flush buffered receivers
57
57
  bufferedReceivers.forEach(({ type, handler }) => {
58
- client.setOnReciver(type, handler)
58
+ client.setOnReceiver(type, handler)
59
59
  })
60
60
  bufferedReceivers.length = 0
61
61
 
@@ -88,7 +88,7 @@ const senderProxy = new Proxy({}, {
88
88
  // Reserved properties
89
89
  if (prop === 'on') return on
90
90
  if (prop === 'onConnectionChange') return onConnectionChange
91
- if (prop === 'getTransport') return () => resolvedClient?.getTransport?.() || null
91
+ if (prop === 'transport') return resolvedClient?.transport || null
92
92
  if (prop === 'then' || prop === 'catch') return undefined // Not a Promise
93
93
 
94
94
  // Return a function that either calls directly or buffers
@@ -115,7 +115,7 @@ const senderProxy = new Proxy({}, {
115
115
  */
116
116
  function on(type, handler) {
117
117
  if (resolvedClient) {
118
- resolvedClient.setOnReciver(type, handler)
118
+ resolvedClient.setOnReceiver(type, handler)
119
119
  } else {
120
120
  bufferedReceivers.push({ type, handler })
121
121
  getClient()