api-ape 1.0.2 → 1.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.
- package/README.md +39 -5
- package/client/README.md +32 -0
- package/client/connectSocket.js +288 -5
- package/dist/ape.js +289 -44
- package/package.json +2 -2
- package/server/README.md +44 -0
- package/server/lib/fileTransfer.js +247 -0
- package/server/lib/main.js +70 -2
- package/server/lib/wiring.js +4 -2
- package/server/socket/receive.js +118 -3
- package/server/socket/send.js +97 -2
package/README.md
CHANGED
|
@@ -343,12 +343,46 @@ The client automatically reconnects with exponential backoff. If connections dro
|
|
|
343
343
|
* Verify network stability
|
|
344
344
|
* Check server logs for errors
|
|
345
345
|
|
|
346
|
-
### Binary Data / File
|
|
346
|
+
### Binary Data / File Transfers
|
|
347
347
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
348
|
+
api-ape supports transparent binary file transfers. Simply return `Buffer` data from controllers:
|
|
349
|
+
|
|
350
|
+
```js
|
|
351
|
+
// api/files/download.js
|
|
352
|
+
module.exports = function(filename) {
|
|
353
|
+
return {
|
|
354
|
+
name: filename,
|
|
355
|
+
data: fs.readFileSync(`./uploads/${filename}`) // Buffer
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
The client receives `ArrayBuffer` automatically:
|
|
361
|
+
|
|
362
|
+
```js
|
|
363
|
+
const result = await ape.files.download('image.png')
|
|
364
|
+
console.log(result.data) // ArrayBuffer
|
|
365
|
+
|
|
366
|
+
// Display as image
|
|
367
|
+
const blob = new Blob([result.data])
|
|
368
|
+
img.src = URL.createObjectURL(blob)
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**Uploads work the same way:**
|
|
372
|
+
|
|
373
|
+
```js
|
|
374
|
+
// Client
|
|
375
|
+
const arrayBuffer = await file.arrayBuffer()
|
|
376
|
+
await ape.files.upload({ name: file.name, data: arrayBuffer })
|
|
377
|
+
|
|
378
|
+
// Server (api/files/upload.js)
|
|
379
|
+
module.exports = function({ name, data }) {
|
|
380
|
+
fs.writeFileSync(`./uploads/${name}`, data) // data is Buffer
|
|
381
|
+
return { success: true }
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Binary data is transferred via temporary HTTP endpoints (`/api/ape/data/:hash`) with session verification and auto-cleanup.
|
|
352
386
|
|
|
353
387
|
### TypeScript Support
|
|
354
388
|
|
package/client/README.md
CHANGED
|
@@ -67,3 +67,35 @@ ape.configure({
|
|
|
67
67
|
Default port detection:
|
|
68
68
|
- Local (`localhost`, `127.0.0.1`): `9010`
|
|
69
69
|
- Remote: Uses current page port or `443`/`80`
|
|
70
|
+
|
|
71
|
+
## File Transfers
|
|
72
|
+
|
|
73
|
+
Binary data is automatically handled. The client fetches binary resources and uploads binary data transparently.
|
|
74
|
+
|
|
75
|
+
### Receiving Binary Data
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
// Server returns Buffer, client receives ArrayBuffer
|
|
79
|
+
const result = await ape.files.download('image.png')
|
|
80
|
+
console.log(result.data) // ArrayBuffer
|
|
81
|
+
|
|
82
|
+
// Display as image
|
|
83
|
+
const blob = new Blob([result.data])
|
|
84
|
+
img.src = URL.createObjectURL(blob)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Uploading Binary Data
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
const file = input.files[0]
|
|
91
|
+
const arrayBuffer = await file.arrayBuffer()
|
|
92
|
+
|
|
93
|
+
// Binary data is uploaded automatically
|
|
94
|
+
await ape.files.upload({
|
|
95
|
+
name: file.name,
|
|
96
|
+
data: arrayBuffer // Sent via HTTP PUT
|
|
97
|
+
})
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Binary transfers use `/api/ape/data/:hash` endpoints with session verification.
|
|
101
|
+
|
package/client/connectSocket.js
CHANGED
|
@@ -101,14 +101,148 @@ function connectSocket() {
|
|
|
101
101
|
aWaitingSend = []
|
|
102
102
|
} // END onopen
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Find all L-tagged (binary link) properties in data
|
|
106
|
+
* Returns array of { path, hash }
|
|
107
|
+
*/
|
|
108
|
+
function findLinkedResources(obj, path = '') {
|
|
109
|
+
const resources = []
|
|
110
|
+
|
|
111
|
+
if (obj === null || obj === undefined || typeof obj !== 'object') {
|
|
112
|
+
return resources
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (Array.isArray(obj)) {
|
|
116
|
+
for (let i = 0; i < obj.length; i++) {
|
|
117
|
+
resources.push(...findLinkedResources(obj[i], path ? `${path}.${i}` : String(i)))
|
|
118
|
+
}
|
|
119
|
+
return resources
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const key of Object.keys(obj)) {
|
|
123
|
+
// Check for L-tag in key (from JJS encoding: key<!L>)
|
|
124
|
+
if (key.endsWith('<!L>')) {
|
|
125
|
+
const cleanKey = key.slice(0, -4)
|
|
126
|
+
const hash = obj[key]
|
|
127
|
+
resources.push({
|
|
128
|
+
path: path ? `${path}.${cleanKey}` : cleanKey,
|
|
129
|
+
hash,
|
|
130
|
+
originalKey: key
|
|
131
|
+
})
|
|
132
|
+
} else {
|
|
133
|
+
resources.push(...findLinkedResources(obj[key], path ? `${path}.${key}` : key))
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return resources
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Set a value at a nested path in an object
|
|
142
|
+
*/
|
|
143
|
+
function setValueAtPath(obj, path, value) {
|
|
144
|
+
const parts = path.split('.')
|
|
145
|
+
let current = obj
|
|
146
|
+
|
|
147
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
148
|
+
current = current[parts[i]]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
current[parts[parts.length - 1]] = value
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Clean up L-tagged keys (rename key<!L> to key)
|
|
156
|
+
*/
|
|
157
|
+
function cleanLinkedKeys(obj) {
|
|
158
|
+
if (obj === null || obj === undefined || typeof obj !== 'object') {
|
|
159
|
+
return obj
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (Array.isArray(obj)) {
|
|
163
|
+
return obj.map(cleanLinkedKeys)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const cleaned = {}
|
|
167
|
+
for (const key of Object.keys(obj)) {
|
|
168
|
+
if (key.endsWith('<!L>')) {
|
|
169
|
+
const cleanKey = key.slice(0, -4)
|
|
170
|
+
cleaned[cleanKey] = obj[key] // Value will be replaced after fetch
|
|
171
|
+
} else {
|
|
172
|
+
cleaned[key] = cleanLinkedKeys(obj[key])
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return cleaned
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Fetch binary resources and hydrate data object
|
|
180
|
+
*/
|
|
181
|
+
async function fetchLinkedResources(data, hostId) {
|
|
182
|
+
const resources = findLinkedResources(data)
|
|
183
|
+
|
|
184
|
+
if (resources.length === 0) {
|
|
185
|
+
return data
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log(`🦍 Fetching ${resources.length} binary resource(s)`)
|
|
189
|
+
|
|
190
|
+
// Clean the data first (remove <!L> suffixes from keys)
|
|
191
|
+
const cleanedData = cleanLinkedKeys(data)
|
|
192
|
+
|
|
193
|
+
// Build base URL for fetches
|
|
194
|
+
const hostname = configuredHost || window.location.hostname
|
|
195
|
+
const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(hostname)
|
|
196
|
+
const isHttps = window.location.protocol === "https:"
|
|
197
|
+
const defaultPort = isLocal ? 9010 : (window.location.port || (isHttps ? 443 : 80))
|
|
198
|
+
const port = configuredPort || defaultPort
|
|
199
|
+
const protocol = isHttps ? "https" : "http"
|
|
200
|
+
const portSuffix = (isLocal || (port !== 80 && port !== 443)) ? `:${port}` : ""
|
|
201
|
+
const baseUrl = `${protocol}://${hostname}${portSuffix}`
|
|
202
|
+
|
|
203
|
+
// Fetch all resources in parallel
|
|
204
|
+
await Promise.all(resources.map(async ({ path, hash }) => {
|
|
205
|
+
try {
|
|
206
|
+
const response = await fetch(`${baseUrl}/api/ape/data/${hash}`, {
|
|
207
|
+
credentials: 'include',
|
|
208
|
+
headers: {
|
|
209
|
+
'X-Ape-Host-Id': hostId || ''
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
throw new Error(`Failed to fetch binary resource: ${response.status}`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const arrayBuffer = await response.arrayBuffer()
|
|
218
|
+
setValueAtPath(cleanedData, path, arrayBuffer)
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error(`🦍 Failed to fetch binary resource at ${path}:`, err)
|
|
221
|
+
setValueAtPath(cleanedData, path, null)
|
|
222
|
+
}
|
|
223
|
+
}))
|
|
224
|
+
|
|
225
|
+
return cleanedData
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
__socket.onmessage = async function (event) {
|
|
105
229
|
//console.log('WebSocket message:', event);
|
|
106
230
|
const { err, type, queryId, data } = jss.parse(event.data)
|
|
107
231
|
|
|
108
232
|
// Messages with queryId must fulfill matching promise
|
|
109
233
|
if (queryId) {
|
|
110
234
|
if (waitingOn[queryId]) {
|
|
111
|
-
|
|
235
|
+
// Check for linked resources and fetch them before resolving
|
|
236
|
+
if (data && !err) {
|
|
237
|
+
try {
|
|
238
|
+
const hydratedData = await fetchLinkedResources(data)
|
|
239
|
+
waitingOn[queryId](err, hydratedData)
|
|
240
|
+
} catch (fetchErr) {
|
|
241
|
+
waitingOn[queryId](fetchErr, null)
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
waitingOn[queryId](err, data)
|
|
245
|
+
}
|
|
112
246
|
delete waitingOn[queryId]
|
|
113
247
|
} else {
|
|
114
248
|
// No matching promise - error and ignore
|
|
@@ -118,10 +252,20 @@ function connectSocket() {
|
|
|
118
252
|
}
|
|
119
253
|
|
|
120
254
|
// Only messages WITHOUT queryId go to setOnReciver
|
|
255
|
+
// Also hydrate broadcast messages
|
|
256
|
+
let processedData = data
|
|
257
|
+
if (data && !err) {
|
|
258
|
+
try {
|
|
259
|
+
processedData = await fetchLinkedResources(data)
|
|
260
|
+
} catch (fetchErr) {
|
|
261
|
+
console.error(`🦍 Failed to hydrate broadcast data:`, fetchErr)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
121
265
|
if (ofTypesOb[type]) {
|
|
122
|
-
ofTypesOb[type].forEach(worker => worker({ err, type, data }))
|
|
266
|
+
ofTypesOb[type].forEach(worker => worker({ err, type, data: processedData }))
|
|
123
267
|
} // if ofTypesOb[type]
|
|
124
|
-
reciverOnAr.forEach(worker => worker({ err, type, data }))
|
|
268
|
+
reciverOnAr.forEach(worker => worker({ err, type, data: processedData }))
|
|
125
269
|
|
|
126
270
|
} // END onmessage
|
|
127
271
|
|
|
@@ -137,6 +281,133 @@ function connectSocket() {
|
|
|
137
281
|
} // END onclose
|
|
138
282
|
|
|
139
283
|
} // END if ! __socket
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Check if value is binary data (ArrayBuffer, typed array, or Blob)
|
|
287
|
+
*/
|
|
288
|
+
function isBinaryData(value) {
|
|
289
|
+
if (value === null || value === undefined) return false
|
|
290
|
+
return value instanceof ArrayBuffer ||
|
|
291
|
+
ArrayBuffer.isView(value) ||
|
|
292
|
+
(typeof Blob !== 'undefined' && value instanceof Blob)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Get binary type tag (A for ArrayBuffer, B for Blob)
|
|
297
|
+
*/
|
|
298
|
+
function getBinaryTag(value) {
|
|
299
|
+
if (typeof Blob !== 'undefined' && value instanceof Blob) return 'B'
|
|
300
|
+
return 'A'
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Generate a simple hash for binary upload
|
|
305
|
+
*/
|
|
306
|
+
function generateUploadHash(path) {
|
|
307
|
+
let hash = 0
|
|
308
|
+
for (let i = 0; i < path.length; i++) {
|
|
309
|
+
const char = path.charCodeAt(i)
|
|
310
|
+
hash = ((hash << 5) - hash) + char
|
|
311
|
+
hash = hash & hash
|
|
312
|
+
}
|
|
313
|
+
return Math.abs(hash).toString(36)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Find and extract binary data from payload
|
|
318
|
+
* Returns { processedData, uploads: [{ path, hash, data, tag }] }
|
|
319
|
+
*/
|
|
320
|
+
function processBinaryForUpload(data, path = '') {
|
|
321
|
+
if (data === null || data === undefined) {
|
|
322
|
+
return { processedData: data, uploads: [] }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (isBinaryData(data)) {
|
|
326
|
+
const tag = getBinaryTag(data)
|
|
327
|
+
const hash = generateUploadHash(path || 'root')
|
|
328
|
+
return {
|
|
329
|
+
processedData: { [`__ape_upload__`]: hash },
|
|
330
|
+
uploads: [{ path, hash, data, tag }]
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (Array.isArray(data)) {
|
|
335
|
+
const processedArray = []
|
|
336
|
+
const allUploads = []
|
|
337
|
+
|
|
338
|
+
for (let i = 0; i < data.length; i++) {
|
|
339
|
+
const itemPath = path ? `${path}.${i}` : String(i)
|
|
340
|
+
const { processedData, uploads } = processBinaryForUpload(data[i], itemPath)
|
|
341
|
+
processedArray.push(processedData)
|
|
342
|
+
allUploads.push(...uploads)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { processedData: processedArray, uploads: allUploads }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (typeof data === 'object') {
|
|
349
|
+
const processedObj = {}
|
|
350
|
+
const allUploads = []
|
|
351
|
+
|
|
352
|
+
for (const key of Object.keys(data)) {
|
|
353
|
+
const itemPath = path ? `${path}.${key}` : key
|
|
354
|
+
const { processedData, uploads } = processBinaryForUpload(data[key], itemPath)
|
|
355
|
+
|
|
356
|
+
// If this was binary data, mark the key with <!B> or <!A> tag
|
|
357
|
+
if (uploads.length > 0 && processedData?.__ape_upload__) {
|
|
358
|
+
const tag = uploads[uploads.length - 1].tag
|
|
359
|
+
processedObj[`${key}<!${tag}>`] = processedData.__ape_upload__
|
|
360
|
+
} else {
|
|
361
|
+
processedObj[key] = processedData
|
|
362
|
+
}
|
|
363
|
+
allUploads.push(...uploads)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { processedData: processedObj, uploads: allUploads }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { processedData: data, uploads: [] }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Upload binary data via HTTP PUT
|
|
374
|
+
*/
|
|
375
|
+
async function uploadBinaryData(queryId, uploads) {
|
|
376
|
+
if (uploads.length === 0) return
|
|
377
|
+
|
|
378
|
+
// Build base URL
|
|
379
|
+
const hostname = configuredHost || window.location.hostname
|
|
380
|
+
const isLocal = ["localhost", "127.0.0.1", "[::1]"].includes(hostname)
|
|
381
|
+
const isHttps = window.location.protocol === "https:"
|
|
382
|
+
const defaultPort = isLocal ? 9010 : (window.location.port || (isHttps ? 443 : 80))
|
|
383
|
+
const port = configuredPort || defaultPort
|
|
384
|
+
const protocol = isHttps ? "https" : "http"
|
|
385
|
+
const portSuffix = (isLocal || (port !== 80 && port !== 443)) ? `:${port}` : ""
|
|
386
|
+
const baseUrl = `${protocol}://${hostname}${portSuffix}`
|
|
387
|
+
|
|
388
|
+
console.log(`🦍 Uploading ${uploads.length} binary file(s)`)
|
|
389
|
+
|
|
390
|
+
await Promise.all(uploads.map(async ({ hash, data }) => {
|
|
391
|
+
try {
|
|
392
|
+
const response = await fetch(`${baseUrl}/api/ape/data/${queryId}/${hash}`, {
|
|
393
|
+
method: 'PUT',
|
|
394
|
+
credentials: 'include',
|
|
395
|
+
headers: {
|
|
396
|
+
'Content-Type': 'application/octet-stream'
|
|
397
|
+
},
|
|
398
|
+
body: data
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
if (!response.ok) {
|
|
402
|
+
throw new Error(`Upload failed: ${response.status}`)
|
|
403
|
+
}
|
|
404
|
+
} catch (err) {
|
|
405
|
+
console.error(`🦍 Failed to upload binary at ${hash}:`, err)
|
|
406
|
+
throw err
|
|
407
|
+
}
|
|
408
|
+
}))
|
|
409
|
+
}
|
|
410
|
+
|
|
140
411
|
wsSend = function (type, data, createdAt, dirctCall) {
|
|
141
412
|
let rej, promiseIsLive = false;
|
|
142
413
|
const timeLetForReqToBeMade = (createdAt + totalRequestTimeout) - Date.now()
|
|
@@ -146,9 +417,13 @@ function connectSocket() {
|
|
|
146
417
|
rej(new Error("Request Timedout for :" + type))
|
|
147
418
|
}
|
|
148
419
|
}, timeLetForReqToBeMade);
|
|
420
|
+
|
|
421
|
+
// Process binary data for upload
|
|
422
|
+
const { processedData, uploads } = processBinaryForUpload(data)
|
|
423
|
+
|
|
149
424
|
const payload = {
|
|
150
425
|
type,
|
|
151
|
-
data,
|
|
426
|
+
data: processedData,
|
|
152
427
|
//referer:window.location.href,
|
|
153
428
|
createdAt: new Date(createdAt),
|
|
154
429
|
requestedAt: dirctCall ? undefined
|
|
@@ -169,6 +444,14 @@ function connectSocket() {
|
|
|
169
444
|
}
|
|
170
445
|
}
|
|
171
446
|
__socket.send(message);
|
|
447
|
+
|
|
448
|
+
// Upload binary data after sending WS message
|
|
449
|
+
if (uploads.length > 0) {
|
|
450
|
+
uploadBinaryData(queryId, uploads).catch(err => {
|
|
451
|
+
console.error('🦍 Binary upload failed:', err)
|
|
452
|
+
// The server will timeout waiting for the upload
|
|
453
|
+
})
|
|
454
|
+
}
|
|
172
455
|
});
|
|
173
456
|
const next = replyPromise.then;
|
|
174
457
|
replyPromise.then = worker => {
|