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 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 Uploads
346
+ ### Binary Data / File Transfers
347
347
 
348
- JJS encoding supports complex types, but for large binary data, consider:
349
- * Sending file URLs instead of raw data
350
- * Using a separate file upload endpoint
351
- * Chunking large payloads
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
+
@@ -101,14 +101,148 @@ function connectSocket() {
101
101
  aWaitingSend = []
102
102
  } // END onopen
103
103
 
104
- __socket.onmessage = function (event) {
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
- waitingOn[queryId](err, data)
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 => {