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/server/index.js CHANGED
@@ -4,11 +4,11 @@
4
4
  */
5
5
 
6
6
  const ape = require('./lib/main')
7
- const { broadcast, online, getClients } = require('./lib/broadcast')
7
+ const { broadcast, clients } = require('./lib/broadcast')
8
8
 
9
9
  // Attach broadcast utilities to the main function for clean exports
10
10
  ape.broadcast = broadcast
11
- ape.online = online
12
- ape.getClients = getClients
11
+ ape.clients = clients
13
12
 
14
13
  module.exports = ape
14
+
@@ -1,40 +1,120 @@
1
1
  /**
2
- * Broadcast utilities for api-ape
3
- * Tracks connected clients and provides broadcast functions
2
+ * Client tracking and broadcast utilities for api-ape
3
+ * Provides a Map of connected clients with sendTo functionality
4
4
  */
5
5
 
6
- // Track all connected clients for broadcast
7
- const connectedClients = new Set()
6
+ // Internal Map of connected clients: clientId -> ClientWrapper
7
+ const _clients = new Map()
8
8
 
9
9
  /**
10
- * Add a client to the connected set
10
+ * Create a ClientWrapper that exposes client info and sendTo function
11
+ * @param {object} clientInfo - Raw client info from wiring/longPolling
12
+ */
13
+ function createClientWrapper(clientInfo) {
14
+ return {
15
+ get clientId() { return clientInfo.clientId },
16
+ get sessionId() { return clientInfo.sessionId || null },
17
+ get embed() { return clientInfo.embed || {} },
18
+ get agent() { return clientInfo.agent || {} },
19
+ /**
20
+ * Send a message to this specific client
21
+ * @param {string} type - Message type
22
+ * @param {any} data - Data to send
23
+ */
24
+ sendTo(type, data) {
25
+ if (clientInfo.send) {
26
+ try {
27
+ clientInfo.send(false, type, data, false)
28
+ } catch (e) {
29
+ console.error(`📢 sendTo failed for ${clientInfo.clientId}:`, e.message)
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Read-only proxy for the clients Map
38
+ * Allows: get, has, keys, values, entries, forEach, size, iteration
39
+ * Prevents: set, delete, clear (throws error if attempted)
40
+ */
41
+ const clients = new Proxy(_clients, {
42
+ get(target, prop) {
43
+ // Prevent mutation methods
44
+ if (prop === 'set' || prop === 'delete' || prop === 'clear') {
45
+ return () => {
46
+ throw new Error(`ape.clients.${prop}() is not allowed. Clients are managed internally by api-ape.`)
47
+ }
48
+ }
49
+
50
+ // Allow size property
51
+ if (prop === 'size') {
52
+ return target.size
53
+ }
54
+
55
+ // Bind methods to target
56
+ const value = target[prop]
57
+ if (typeof value === 'function') {
58
+ return value.bind(target)
59
+ }
60
+
61
+ return value
62
+ }
63
+ })
64
+
65
+ /**
66
+ * Add a client to the connected map (internal use only)
67
+ * @param {object} clientInfo - { clientId, sessionId, agent, embed, send }
11
68
  */
12
69
  function addClient(clientInfo) {
13
- connectedClients.add(clientInfo)
14
- console.log(`🟢 Client added: ${clientInfo.clientId} (total: ${connectedClients.size})`)
70
+ const wrapper = createClientWrapper(clientInfo)
71
+ _clients.set(clientInfo.clientId, wrapper)
72
+
73
+ // Store reference to raw info so we can update embed later if needed
74
+ wrapper._raw = clientInfo
75
+
76
+ console.log(`🟢 Client added: ${clientInfo.clientId} (total: ${_clients.size})`)
15
77
  }
16
78
 
17
79
  /**
18
- * Remove a client from the connected set
19
- * Accepts either the client object or { clientId } for lookup
80
+ * Remove a client from the connected map (internal use only)
81
+ * @param {string|object} clientIdOrInfo - clientId string or { clientId } object
20
82
  */
21
- function removeClient(clientInfo) {
22
- const sizeBefore = connectedClients.size
23
- // If exact reference found, delete it
24
- if (connectedClients.has(clientInfo)) {
25
- connectedClients.delete(clientInfo)
26
- console.log(`🔴 Client removed (ref): ${clientInfo.clientId} (total: ${connectedClients.size})`)
27
- return
83
+ function removeClient(clientIdOrInfo) {
84
+ const clientId = typeof clientIdOrInfo === 'string'
85
+ ? clientIdOrInfo
86
+ : clientIdOrInfo.clientId
87
+
88
+ if (_clients.has(clientId)) {
89
+ _clients.delete(clientId)
90
+ console.log(`🔴 Client removed: ${clientId} (total: ${_clients.size})`)
91
+ } else {
92
+ console.log(`⚠️ Client not found for removal: ${clientId} (total: ${_clients.size})`)
28
93
  }
29
- // Otherwise search by clientId (needed for long polling cleanup)
30
- for (const client of connectedClients) {
31
- if (client.clientId === clientInfo.clientId) {
32
- connectedClients.delete(client)
33
- console.log(`🔴 Client removed (lookup): ${clientInfo.clientId} (total: ${connectedClients.size})`)
34
- return
35
- }
94
+ }
95
+
96
+ /**
97
+ * Update a client's embed values after onConnect resolves (internal use only)
98
+ * @param {string} clientId
99
+ * @param {object} embed
100
+ */
101
+ function updateClientEmbed(clientId, embed) {
102
+ const wrapper = _clients.get(clientId)
103
+ if (wrapper && wrapper._raw) {
104
+ wrapper._raw.embed = embed
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Update a client's send function after it's ready (internal use only)
110
+ * @param {string} clientId
111
+ * @param {function} send
112
+ */
113
+ function updateClientSend(clientId, send) {
114
+ const wrapper = _clients.get(clientId)
115
+ if (wrapper && wrapper._raw) {
116
+ wrapper._raw.send = send
36
117
  }
37
- console.log(`⚠️ Client not found for removal: ${clientInfo.clientId} (total: ${connectedClients.size})`)
38
118
  }
39
119
 
40
120
  /**
@@ -44,37 +124,23 @@ function removeClient(clientInfo) {
44
124
  * @param {string} [excludeClientId] - Optional clientId to exclude (e.g., sender)
45
125
  */
46
126
  function broadcast(type, data, excludeClientId) {
47
- console.log(`📢 Broadcasting "${type}" to ${connectedClients.size} clients`, excludeClientId ? `(excluding ${excludeClientId})` : '')
48
- connectedClients.forEach(client => {
49
- if (excludeClientId && client.clientId === excludeClientId) {
127
+ console.log(`📢 Broadcasting "${type}" to ${_clients.size} clients`, excludeClientId ? `(excluding ${excludeClientId})` : '')
128
+ _clients.forEach((wrapper, clientId) => {
129
+ if (excludeClientId && clientId === excludeClientId) {
50
130
  return // Skip excluded client
51
131
  }
52
- try {
53
- client.send(false, type, data, false)
54
- } catch (e) {
55
- console.error(`📢 Broadcast failed to ${client.clientId}:`, e.message)
56
- }
132
+ wrapper.sendTo(type, data)
57
133
  })
58
134
  }
59
135
 
60
- /**
61
- * Get count of online clients
62
- */
63
- function online() {
64
- return connectedClients.size
65
- }
66
-
67
- /**
68
- * Get all connected client clientIds
69
- */
70
- function getClients() {
71
- return Array.from(connectedClients).map(c => c.clientId)
72
- }
73
-
74
136
  module.exports = {
137
+ // Public: read-only clients Map
138
+ clients,
139
+ // Public: broadcast function
140
+ broadcast,
141
+ // Internal: client management (used by wiring.js and longPolling.js)
75
142
  addClient,
76
143
  removeClient,
77
- broadcast,
78
- online,
79
- getClients
144
+ updateClientEmbed,
145
+ updateClientSend
80
146
  }
package/server/lib/bun.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * ```ts
7
7
  * import { apeBun } from 'api-ape/bun'
8
8
  *
9
- * const ape = apeBun({ where: 'api', onConnent: ... })
9
+ * const ape = apeBun({ where: 'api', onConnect: ... })
10
10
  *
11
11
  * Bun.serve({
12
12
  * port: 3000,
@@ -25,15 +25,15 @@ const { BunWebSocket, BunWebSocketServer } = require('./ws/adapters/bun')
25
25
 
26
26
  /**
27
27
  * Create api-ape handlers for Bun.serve()
28
- * @param {{ where: string, onConnent?: Function, fileTransferOptions?: Object }} options
28
+ * @param {{ where: string, onConnect?: Function, fileTransferOptions?: Object }} options
29
29
  */
30
- function apeBun({ where, onConnent, fileTransferOptions }) {
30
+ function apeBun({ where, onConnect, fileTransferOptions }) {
31
31
  const controllers = loader(where)
32
32
  const fileTransfer = getFileTransferManager(fileTransferOptions)
33
33
  const wss = new BunWebSocketServer({ noServer: true })
34
34
 
35
35
  const wsPath = `/${where}/ape`
36
- const wiringHandler = wiring(controllers, onConnent, fileTransfer)
36
+ const wiringHandler = wiring(controllers, onConnect, fileTransfer)
37
37
 
38
38
  // Handle connections
39
39
  wss.on('connection', wiringHandler)
@@ -28,10 +28,124 @@ class FileTransferManager {
28
28
  // Map<`${queryId}/${pathHash}`, { sessionHostId, createdAt, resolver, rejector, timer, data }>
29
29
  this.pendingUploads = new Map()
30
30
 
31
+ // Map<fileId, StreamingFileEntry> - for client-to-client streaming
32
+ // StreamingFileEntry: { uploaderId, chunks[], totalReceived, isComplete, createdAt, timer }
33
+ this.streamingFiles = new Map()
34
+
31
35
  // Cleanup interval
32
36
  this._cleanupInterval = setInterval(() => this._cleanup(), 30000)
33
37
  }
34
38
 
39
+ /**
40
+ * Register a streaming file (client-to-client transfer)
41
+ * Called when <!F> tag is detected in incoming message
42
+ * @param {string} fileId - Unique file identifier
43
+ * @param {string} uploaderId - Client ID of uploader
44
+ * @returns {string} The fileId
45
+ */
46
+ registerStreamingFile(fileId, uploaderId) {
47
+ // Clear existing entry if any
48
+ if (this.streamingFiles.has(fileId)) {
49
+ const existing = this.streamingFiles.get(fileId)
50
+ if (existing.timer) clearTimeout(existing.timer)
51
+ }
52
+
53
+ const entry = {
54
+ uploaderId,
55
+ chunks: [],
56
+ totalReceived: 0,
57
+ isComplete: false,
58
+ createdAt: Date.now(),
59
+ timer: setTimeout(() => {
60
+ this.streamingFiles.delete(fileId)
61
+ console.log(`📦 Streaming file expired: ${fileId}`)
62
+ }, this.startTimeout + this.completeTimeout)
63
+ }
64
+
65
+ this.streamingFiles.set(fileId, entry)
66
+ console.log(`📦 Registered streaming file: ${fileId} from ${uploaderId}`)
67
+ return fileId
68
+ }
69
+
70
+ /**
71
+ * Append a chunk to a streaming file
72
+ * @param {string} fileId - File identifier
73
+ * @param {Buffer} chunk - Data chunk
74
+ * @returns {boolean} True if accepted
75
+ */
76
+ appendChunk(fileId, chunk) {
77
+ const entry = this.streamingFiles.get(fileId)
78
+ if (!entry) {
79
+ console.warn(`📦 Streaming file not found: ${fileId}`)
80
+ return false
81
+ }
82
+
83
+ entry.chunks.push(chunk)
84
+ entry.totalReceived += chunk.length
85
+ return true
86
+ }
87
+
88
+ /**
89
+ * Mark streaming file as complete
90
+ * @param {string} fileId - File identifier
91
+ * @param {Buffer} data - Complete file data (if not chunked)
92
+ * @returns {boolean} True if successful
93
+ */
94
+ completeStreamingUpload(fileId, data) {
95
+ const entry = this.streamingFiles.get(fileId)
96
+ if (!entry) {
97
+ console.warn(`📦 Streaming file not found for completion: ${fileId}`)
98
+ return false
99
+ }
100
+
101
+ if (data) {
102
+ entry.chunks = [data]
103
+ entry.totalReceived = data.length
104
+ }
105
+ entry.isComplete = true
106
+
107
+ // Reset timer for cleanup after completion
108
+ clearTimeout(entry.timer)
109
+ entry.timer = setTimeout(() => {
110
+ this.streamingFiles.delete(fileId)
111
+ console.log(`📦 Streaming file cleaned up: ${fileId}`)
112
+ }, this.completeTimeout)
113
+
114
+ console.log(`📦 Streaming upload complete: ${fileId} (${entry.totalReceived} bytes)`)
115
+ return true
116
+ }
117
+
118
+ /**
119
+ * Get streaming file data (available bytes so far)
120
+ * @param {string} fileId - File identifier
121
+ * @param {number} offset - Byte offset to start from (for resumable downloads)
122
+ * @returns {{ data: Buffer, isComplete: boolean, totalReceived: number } | null}
123
+ */
124
+ getStreamingFile(fileId, offset = 0) {
125
+ const entry = this.streamingFiles.get(fileId)
126
+ if (!entry) {
127
+ return null
128
+ }
129
+
130
+ // Concatenate chunks
131
+ const data = Buffer.concat(entry.chunks)
132
+
133
+ return {
134
+ data: offset > 0 ? data.slice(offset) : data,
135
+ isComplete: entry.isComplete,
136
+ totalReceived: entry.totalReceived
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Check if a file ID is a streaming file
142
+ * @param {string} fileId - File identifier
143
+ * @returns {boolean}
144
+ */
145
+ isStreamingFile(fileId) {
146
+ return this.streamingFiles.has(fileId)
147
+ }
148
+
35
149
  /**
36
150
  * Register a binary download
37
151
  * @param {string} hash - Unique hash for this download
@@ -210,6 +324,15 @@ class FileTransferManager {
210
324
  console.log(`📤 Cleanup stale upload: ${key}`)
211
325
  }
212
326
  }
327
+
328
+ // Cleanup streaming files
329
+ for (const [fileId, entry] of this.streamingFiles) {
330
+ if (now - entry.createdAt > maxAge) {
331
+ clearTimeout(entry.timer)
332
+ this.streamingFiles.delete(fileId)
333
+ console.log(`📦 Cleanup stale streaming file: ${fileId}`)
334
+ }
335
+ }
213
336
  }
214
337
 
215
338
  /**
@@ -228,6 +351,12 @@ class FileTransferManager {
228
351
 
229
352
  this.pendingDownloads.clear()
230
353
  this.pendingUploads.clear()
354
+
355
+ // Clear streaming file timers
356
+ for (const entry of this.streamingFiles.values()) {
357
+ clearTimeout(entry.timer)
358
+ }
359
+ this.streamingFiles.clear()
231
360
  }
232
361
  }
233
362
 
@@ -1,6 +1,7 @@
1
- const { addClient, removeClient, broadcast } = require('./broadcast')
1
+ const { addClient, removeClient, broadcast, clients, updateClientEmbed } = require('./broadcast')
2
2
  const makeid = require('../utils/genId')
3
3
  const jss = require('../../utils/jss')
4
+ const parseUserAgent = require('../utils/parseUserAgent')
4
5
 
5
6
  // Active streaming connections: clientId -> { res, messageQueue, heartbeatTimer }
6
7
  const streamClients = new Map()
@@ -45,7 +46,7 @@ function sendJson(res, statusCode, data) {
45
46
  /**
46
47
  * Create long polling handler
47
48
  */
48
- function createLongPollingHandler(controllers, onConnent, fileTransfer) {
49
+ function createLongPollingHandler(controllers, onConnect, fileTransfer) {
49
50
 
50
51
  /**
51
52
  * Handle GET /api/ape/poll - Streaming receive
@@ -116,26 +117,33 @@ function createLongPollingHandler(controllers, onConnent, fileTransfer) {
116
117
  }
117
118
  }, 20000)
118
119
 
119
- // Register client for broadcasts
120
- const clientInfo = { clientId, send }
121
- addClient(clientInfo)
120
+ // Extract sessionId from cookies
121
+ const sessionIdMatch = (req.headers.cookie || '').match(/(?:^|;\s*)sessionId=([^;]*)/)
122
+ const sessionId = sessionIdMatch ? sessionIdMatch[1] : null
123
+
124
+ // Parse user agent
125
+ const agent = parseUserAgent(req.headers['user-agent'])
126
+
127
+ // Register client for broadcasts with full metadata
128
+ addClient({ clientId, sessionId, agent, send, embed: null })
122
129
  streamClients.set(clientId, clientState)
123
130
 
124
- // Call onConnent hook if provided
125
- if (onConnent) {
126
- Promise.resolve(onConnent(null, req, send))
131
+ // Call onConnect hook if provided
132
+ if (onConnect) {
133
+ Promise.resolve(onConnect(null, req, send))
127
134
  .then(handlers => {
128
135
  if (handlers) {
129
- if (handlers.onDisconnent) {
130
- clientState.onDisconnect = handlers.onDisconnent
136
+ if (handlers.onDisconnect) {
137
+ clientState.onDisconnect = handlers.onDisconnect
131
138
  }
132
139
  if (handlers.embed) {
133
140
  clientState.embed = handlers.embed
141
+ updateClientEmbed(clientId, handlers.embed)
134
142
  }
135
143
  }
136
144
  })
137
145
  .catch(err => {
138
- console.error('onConnent error:', err)
146
+ console.error('onConnect error:', err)
139
147
  })
140
148
  }
141
149
 
@@ -193,8 +201,9 @@ function createLongPollingHandler(controllers, onConnent, fileTransfer) {
193
201
  req,
194
202
  broadcast: (t, d) => broadcast(t, d),
195
203
  broadcastOthers: (t, d) => broadcast(t, d, clientId),
196
- online: () => streamClients.size,
197
- getClients: () => Array.from(streamClients.keys())
204
+ // Use clients Map for count and list
205
+ online: () => clients.size,
206
+ getClients: () => Array.from(clients.keys())
198
207
  }
199
208
 
200
209
  // Execute controller
@@ -73,11 +73,11 @@ function isSecure(req) {
73
73
  /**
74
74
  * Create core api-ape handlers (shared between runtimes)
75
75
  */
76
- function createApeCore({ where, onConnent, fileTransferOptions }) {
76
+ function createApeCore({ where, onConnect, fileTransferOptions }) {
77
77
  const controllers = loader(where)
78
78
  const fileTransfer = getFileTransferManager(fileTransferOptions)
79
- const wiringHandler = wiring(controllers, onConnent, fileTransfer)
80
- const { handleStreamGet, handleStreamPost } = createLongPollingHandler(controllers, onConnent, fileTransfer)
79
+ const wiringHandler = wiring(controllers, onConnect, fileTransfer)
80
+ const { handleStreamGet, handleStreamPost } = createLongPollingHandler(controllers, onConnect, fileTransfer)
81
81
 
82
82
  const wsPath = `/${where}/ape`
83
83
  const pollPath = `/${where}/ape/poll`
@@ -185,6 +185,26 @@ function initNodeServer(server, options) {
185
185
  const downloadMatch = matchRoute(pathname, core.downloadPattern)
186
186
  if (req.method === 'GET' && downloadMatch) {
187
187
  const { hash } = downloadMatch
188
+
189
+ // Check for streaming file first (client-to-client sharing, no session check)
190
+ const streamingFile = core.fileTransfer.getStreamingFile(hash)
191
+ if (streamingFile) {
192
+ if (!isLocalhost(req.headers.host) && !isSecure(req)) {
193
+ return sendJson(res, 403, { error: 'HTTPS required for file transfers' })
194
+ }
195
+
196
+ // Return available data (may be partial if upload still in progress)
197
+ res.writeHead(200, {
198
+ 'Content-Type': 'application/octet-stream',
199
+ 'Content-Length': streamingFile.data.length,
200
+ 'X-Ape-Complete': streamingFile.isComplete ? '1' : '0',
201
+ 'X-Ape-Total-Received': String(streamingFile.totalReceived)
202
+ })
203
+ res.end(streamingFile.data)
204
+ return
205
+ }
206
+
207
+ // Session-bound download (original behavior)
188
208
  const clientId = getCookie(req.headers, 'apeClientId') || req.headers['x-ape-client-id']
189
209
 
190
210
  if (!clientId) {
@@ -213,11 +233,6 @@ function initNodeServer(server, options) {
213
233
  const uploadMatch = matchRoute(pathname, core.uploadPattern)
214
234
  if (req.method === 'PUT' && uploadMatch) {
215
235
  const { queryId, pathHash } = uploadMatch
216
- const clientId = getCookie(req.headers, 'apeClientId') || req.headers['x-ape-client-id']
217
-
218
- if (!clientId) {
219
- return sendJson(res, 401, { error: 'Missing session identifier' })
220
- }
221
236
 
222
237
  if (!isLocalhost(req.headers.host) && !isSecure(req)) {
223
238
  return sendJson(res, 403, { error: 'HTTPS required for file transfers' })
@@ -227,6 +242,23 @@ function initNodeServer(server, options) {
227
242
  req.on('data', chunk => chunks.push(chunk))
228
243
  req.on('end', () => {
229
244
  const data = Buffer.concat(chunks)
245
+
246
+ // Check if this is a streaming file upload (client-to-client)
247
+ // For streaming files, queryId might be the fileId directly
248
+ if (core.fileTransfer.isStreamingFile(pathHash)) {
249
+ const success = core.fileTransfer.completeStreamingUpload(pathHash, data)
250
+ if (success) {
251
+ return sendJson(res, 200, { success: true, streaming: true })
252
+ }
253
+ return sendJson(res, 404, { error: 'Streaming file not found' })
254
+ }
255
+
256
+ // Regular upload (session-bound)
257
+ const clientId = getCookie(req.headers, 'apeClientId') || req.headers['x-ape-client-id']
258
+ if (!clientId) {
259
+ return sendJson(res, 401, { error: 'Missing session identifier' })
260
+ }
261
+
230
262
  const success = core.fileTransfer.receiveUpload(queryId, pathHash, data, clientId)
231
263
 
232
264
  if (success) {
@@ -4,9 +4,9 @@ const socketReceive = require('../socket/receive')
4
4
  const socketSend = require('../socket/send')
5
5
  const makeid = require('../utils/genId')
6
6
  const parseUserAgent = require('../utils/parseUserAgent');
7
- const { addClient, removeClient } = require('./broadcast')
7
+ const { addClient, removeClient, updateClientEmbed, updateClientSend } = require('./broadcast')
8
8
 
9
- // connent, beforeSend, beforeReceive, error, afterSend, afterReceive, disconnent
9
+ // connect, beforeSend, beforeReceive, error, afterSend, afterReceive, disconnect
10
10
 
11
11
 
12
12
  function defaultEvents(events = {}) {
@@ -15,7 +15,7 @@ function defaultEvents(events = {}) {
15
15
  onReceive: () => { },
16
16
  onSend: () => { },
17
17
  onError: (errSt) => console.error(errSt),
18
- onDisconnent: () => { },
18
+ onDisconnect: () => { },
19
19
  } // END fallBackEvents
20
20
  return Object.assign({}, fallBackEvents, events)
21
21
  } // END defaultEvents
@@ -24,8 +24,8 @@ function defaultEvents(events = {}) {
24
24
  //============================================== wiring
25
25
  //=====================================================
26
26
 
27
- module.exports = function wiring(controllers, onConnent, fileTransfer) {
28
- onConnent = onConnent || (() => { });
27
+ module.exports = function wiring(controllers, onConnect, fileTransfer) {
28
+ onConnect = onConnect || (() => { });
29
29
  return function webSocketHandler(socket, req) {
30
30
 
31
31
  let send;
@@ -40,31 +40,35 @@ module.exports = function wiring(controllers, onConnent, fileTransfer) {
40
40
 
41
41
  const clientId = makeid(20)
42
42
  const agent = parseUserAgent(req.headers['user-agent'])
43
+
44
+ // Extract sessionId from cookies (set by outer framework)
45
+ const sessionIdMatch = (req.headers.cookie || '').match(/(?:^|;\s*)sessionId=([^;]*)/)
46
+ const sessionId = sessionIdMatch ? sessionIdMatch[1] : null
47
+
43
48
  const sharedValues = {
44
49
  socket, req, agent, send: (type, data, err) => sentBufferFn(false, type, data, err)
45
50
  }
46
51
  sharedValues.send.toString = () => clientId
47
52
 
48
- // Track this client for broadcast BEFORE calling onConnent
49
- // This ensures ape.online() returns the correct count when sending init
50
- const clientInfo = { clientId, send: null, embed: null }
51
- addClient(clientInfo)
53
+ // Track this client for broadcast BEFORE calling onConnect
54
+ // This ensures ape.clients.size returns the correct count when sending init
55
+ addClient({ clientId, sessionId, agent, send: null, embed: null })
52
56
 
53
57
  // Remove client on disconnect (set up early, will work once send is assigned)
54
58
  socket.on('close', () => {
55
- removeClient(clientInfo)
59
+ removeClient(clientId)
56
60
  })
57
61
 
58
- let result = onConnent(socket, req, sharedValues.send)
62
+ let result = onConnect(socket, req, sharedValues.send)
59
63
  if (!result || !result.then) {
60
64
  result = Promise.resolve(result)
61
65
  }
62
66
  result.then(defaultEvents)
63
- .then(({ embed, onReceive, onSend, onError, onDisconnent }) => {
67
+ .then(({ embed, onReceive, onSend, onError, onDisconnect }) => {
64
68
  const isOk = socketOpen(socket, req, onError)
65
69
 
66
70
  if (!isOk) {
67
- removeClient(clientInfo) // Clean up if connection fails
71
+ removeClient(clientId) // Clean up if connection fails
68
72
  return;
69
73
  }
70
74
 
@@ -75,7 +79,7 @@ module.exports = function wiring(controllers, onConnent, fileTransfer) {
75
79
  req,
76
80
  clientId,
77
81
  checkReply,
78
- events: { onReceive, onSend, onError, onDisconnent },
82
+ events: { onReceive, onSend, onError, onDisconnect },
79
83
  controllers,
80
84
  sharedValues,
81
85
  embedValues: embed,
@@ -84,13 +88,13 @@ module.exports = function wiring(controllers, onConnent, fileTransfer) {
84
88
  send = socketSend(ape)
85
89
  ape.send = send
86
90
 
87
- // Update clientInfo with real send function and embed
88
- clientInfo.send = send
89
- clientInfo.embed = embed
91
+ // Update client with real send function and embed values
92
+ updateClientSend(clientId, send)
93
+ updateClientEmbed(clientId, embed)
90
94
 
91
- // Call onDisconnent when socket closes
95
+ // Call onDisconnect when socket closes
92
96
  socket.on('close', () => {
93
- onDisconnent()
97
+ onDisconnect()
94
98
  })
95
99
 
96
100
  sentBufferAr.forEach(args => send(...args))