api-ape 2.2.3 → 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 +88 -17
- package/client/browser.js +7 -7
- package/client/connectSocket.js +257 -22
- package/client/index.js +3 -3
- package/dist/ape.js +1 -1
- package/dist/ape.js.map +3 -3
- package/dist/api-ape.min.js +1 -1
- package/dist/api-ape.min.js.map +3 -3
- package/index.d.ts +183 -19
- package/package.json +2 -2
- package/server/README.md +311 -5
- package/server/adapters/README.md +275 -0
- package/server/adapters/firebase.js +172 -0
- package/server/adapters/index.js +144 -0
- package/server/adapters/mongo.js +161 -0
- package/server/adapters/postgres.js +177 -0
- package/server/adapters/redis.js +154 -0
- package/server/adapters/supabase.js +199 -0
- package/server/index.js +3 -3
- package/server/lib/broadcast.js +115 -49
- package/server/lib/bun.js +4 -4
- package/server/lib/fileTransfer.js +129 -0
- package/server/lib/longPolling.js +22 -13
- package/server/lib/main.js +40 -8
- package/server/lib/wiring.js +23 -19
- package/server/socket/receive.js +46 -0
- package/server/socket/send.js +7 -0
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
|
-
| `
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
344
|
+
### Using ape.clients
|
|
285
345
|
|
|
286
346
|
```js
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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 `
|
|
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
|
|
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:
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
})
|
package/client/connectSocket.js
CHANGED
|
@@ -160,13 +160,13 @@ function getSocketUrl() {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
let reconnect = false
|
|
163
|
-
const
|
|
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', '
|
|
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
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 +
|
|
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.
|
|
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,
|
|
794
|
-
const waitingOnOpen = new Promise((res,
|
|
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
|
-
|
|
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 (!
|
|
832
|
-
|
|
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
|
-
|
|
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.
|
|
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 === '
|
|
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.
|
|
118
|
+
resolvedClient.setOnReceiver(type, handler)
|
|
119
119
|
} else {
|
|
120
120
|
bufferedReceivers.push({ type, handler })
|
|
121
121
|
getClient()
|