api-ape 1.0.2 → 2.0.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.
Files changed (42) hide show
  1. package/README.md +63 -16
  2. package/client/README.md +32 -0
  3. package/client/browser.js +7 -1
  4. package/client/connectSocket.js +323 -8
  5. package/dist/ape.js +289 -44
  6. package/example/Bun/README.md +74 -0
  7. package/example/Bun/api/message.ts +11 -0
  8. package/example/Bun/index.html +76 -0
  9. package/example/Bun/package.json +9 -0
  10. package/example/Bun/server.ts +59 -0
  11. package/example/Bun/styles.css +128 -0
  12. package/example/ExpressJs/README.md +5 -7
  13. package/example/ExpressJs/backend.js +23 -21
  14. package/example/NextJs/ape/client.js +3 -3
  15. package/example/NextJs/ape/onConnect.js +5 -5
  16. package/example/NextJs/package-lock.json +1353 -60
  17. package/example/NextJs/package.json +0 -1
  18. package/example/NextJs/pages/index.tsx +21 -10
  19. package/example/NextJs/server.js +7 -11
  20. package/example/README.md +51 -0
  21. package/example/Vite/README.md +68 -0
  22. package/example/Vite/ape/client.ts +66 -0
  23. package/example/Vite/ape/onConnect.ts +52 -0
  24. package/example/Vite/api/message.ts +57 -0
  25. package/example/Vite/index.html +16 -0
  26. package/example/Vite/package.json +19 -0
  27. package/example/Vite/server.ts +62 -0
  28. package/example/Vite/src/App.vue +170 -0
  29. package/example/Vite/src/components/Info.vue +352 -0
  30. package/example/Vite/src/main.ts +5 -0
  31. package/example/Vite/src/style.css +200 -0
  32. package/example/Vite/src/vite-env.d.ts +7 -0
  33. package/example/Vite/vite.config.ts +20 -0
  34. package/index.d.ts +31 -3
  35. package/package.json +2 -3
  36. package/server/README.md +44 -0
  37. package/server/index.js +10 -2
  38. package/server/lib/fileTransfer.js +247 -0
  39. package/server/lib/main.js +172 -9
  40. package/server/lib/wiring.js +4 -2
  41. package/server/socket/receive.js +118 -3
  42. package/server/socket/send.js +97 -2
@@ -1,23 +1,186 @@
1
1
  const loader = require('./loader')
2
2
  const wiring = require('./wiring')
3
- const expressWs = require('express-ws');
4
- const path = require('path');
3
+ const { WebSocketServer } = require('ws')
4
+ const path = require('path')
5
+ const fs = require('fs')
6
+ const { getFileTransferManager } = require('./fileTransfer')
7
+ const { parse: parseUrl } = require('url')
5
8
 
6
9
  let created = false
7
10
 
8
- module.exports = function (app, { where, onConnent }) {
11
+ /**
12
+ * Parse URL path parameters like /api/ape/data/:hash
13
+ * Returns null if pattern doesn't match, or object with params if it does
14
+ */
15
+ function matchRoute(pathname, pattern) {
16
+ const patternParts = pattern.split('/')
17
+ const pathParts = pathname.split('/')
18
+
19
+ if (patternParts.length !== pathParts.length) {
20
+ return null
21
+ }
22
+
23
+ const params = {}
24
+ for (let i = 0; i < patternParts.length; i++) {
25
+ if (patternParts[i].startsWith(':')) {
26
+ params[patternParts[i].slice(1)] = pathParts[i]
27
+ } else if (patternParts[i] !== pathParts[i]) {
28
+ return null
29
+ }
30
+ }
31
+ return params
32
+ }
33
+
34
+ /**
35
+ * Send JSON response
36
+ */
37
+ function sendJson(res, statusCode, data) {
38
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' })
39
+ res.end(JSON.stringify(data))
40
+ }
41
+
42
+ /**
43
+ * Get cookie value from request
44
+ */
45
+ function getCookie(req, name) {
46
+ const cookies = req.headers.cookie
47
+ if (!cookies) return null
48
+ const match = cookies.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
49
+ return match ? match[1] : null
50
+ }
51
+
52
+ /**
53
+ * Check if request is from localhost
54
+ */
55
+ function isLocalhost(req) {
56
+ const host = req.headers.host?.split(':')[0] || ''
57
+ return ['localhost', '127.0.0.1', '[::1]'].includes(host)
58
+ }
59
+
60
+ /**
61
+ * Check if connection is secure (HTTPS)
62
+ */
63
+ function isSecure(req) {
64
+ return req.socket?.encrypted || req.headers['x-forwarded-proto'] === 'https'
65
+ }
66
+
67
+ module.exports = function (server, { where, onConnent, fileTransferOptions }) {
9
68
 
10
69
  if (created) {
11
70
  throw new Error("Api-Ape already started")
12
71
  }
13
- created = true;
14
- expressWs(app)
72
+ created = true
73
+
15
74
  const controllers = loader(where)
75
+ const fileTransfer = getFileTransferManager(fileTransferOptions)
76
+
77
+ // Create WebSocket server attached to the HTTP server
78
+ const wss = new WebSocketServer({ noServer: true })
79
+
80
+ // Handle WebSocket connections
81
+ const wsPath = `/${where}/ape`
82
+ const wiringHandler = wiring(controllers, onConnent, fileTransfer)
16
83
 
17
- // Serve bundled client at /ape.js
18
- app.get('/api/ape.js', (req, res) => {
19
- res.sendFile(path.join(__dirname, '../../dist/ape.js'))
84
+ wss.on('connection', wiringHandler)
85
+
86
+ // Handle HTTP upgrade requests for WebSocket
87
+ server.on('upgrade', (req, socket, head) => {
88
+ const { pathname } = parseUrl(req.url)
89
+
90
+ if (pathname === wsPath) {
91
+ wss.handleUpgrade(req, socket, head, (ws) => {
92
+ wss.emit('connection', ws, req)
93
+ })
94
+ } else {
95
+ socket.destroy()
96
+ }
20
97
  })
21
98
 
22
- app.ws('/api/ape', wiring(controllers, onConnent))
99
+ // Store original request listeners to chain after api-ape handlers
100
+ const originalListeners = server.listeners('request').slice()
101
+ server.removeAllListeners('request')
102
+
103
+ // Handle HTTP requests for api-ape routes
104
+ server.on('request', (req, res) => {
105
+ const { pathname } = parseUrl(req.url)
106
+
107
+ // Serve bundled client at /api/ape.js (or /{where}/ape.js)
108
+ if (pathname === `/${where}/ape.js`) {
109
+ const filePath = path.join(__dirname, '../../dist/ape.js')
110
+ fs.readFile(filePath, (err, data) => {
111
+ if (err) {
112
+ sendJson(res, 500, { error: 'Failed to read client bundle' })
113
+ return
114
+ }
115
+ res.writeHead(200, { 'Content-Type': 'application/javascript' })
116
+ res.end(data)
117
+ })
118
+ return
119
+ }
120
+
121
+ // File download endpoint - GET /api/ape/data/:hash
122
+ const downloadMatch = matchRoute(pathname, `/${where}/ape/data/:hash`)
123
+ if (req.method === 'GET' && downloadMatch) {
124
+ const { hash } = downloadMatch
125
+ const hostId = getCookie(req, 'apeHostId') || req.headers['x-ape-host-id']
126
+
127
+ if (!hostId) {
128
+ return sendJson(res, 401, { error: 'Missing session identifier' })
129
+ }
130
+
131
+ if (!isLocalhost(req) && !isSecure(req)) {
132
+ return sendJson(res, 403, { error: 'HTTPS required for file transfers' })
133
+ }
134
+
135
+ const result = fileTransfer.getDownload(hash, hostId)
136
+
137
+ if (!result) {
138
+ return sendJson(res, 404, { error: 'Download not found or unauthorized' })
139
+ }
140
+
141
+ res.writeHead(200, {
142
+ 'Content-Type': result.contentType,
143
+ 'Content-Length': result.data.length || result.data.byteLength
144
+ })
145
+ res.end(result.data)
146
+ return
147
+ }
148
+
149
+ // File upload endpoint - PUT /api/ape/data/:queryId/:pathHash
150
+ const uploadMatch = matchRoute(pathname, `/${where}/ape/data/:queryId/:pathHash`)
151
+ if (req.method === 'PUT' && uploadMatch) {
152
+ const { queryId, pathHash } = uploadMatch
153
+ const hostId = getCookie(req, 'apeHostId') || req.headers['x-ape-host-id']
154
+
155
+ if (!hostId) {
156
+ return sendJson(res, 401, { error: 'Missing session identifier' })
157
+ }
158
+
159
+ if (!isLocalhost(req) && !isSecure(req)) {
160
+ return sendJson(res, 403, { error: 'HTTPS required for file transfers' })
161
+ }
162
+
163
+ const chunks = []
164
+ req.on('data', chunk => chunks.push(chunk))
165
+ req.on('end', () => {
166
+ const data = Buffer.concat(chunks)
167
+ const success = fileTransfer.receiveUpload(queryId, pathHash, data, hostId)
168
+
169
+ if (success) {
170
+ sendJson(res, 200, { success: true })
171
+ } else {
172
+ sendJson(res, 404, { error: 'Upload not expected or unauthorized' })
173
+ }
174
+ })
175
+ req.on('error', (err) => {
176
+ sendJson(res, 500, { error: err.message })
177
+ })
178
+ return
179
+ }
180
+
181
+ // Not an api-ape route - pass to original handlers
182
+ for (const listener of originalListeners) {
183
+ listener.call(server, req, res)
184
+ }
185
+ })
23
186
  }
@@ -26,7 +26,7 @@ function defaultEvents(events = {}) {
26
26
  //============================================== wiring
27
27
  //=====================================================
28
28
 
29
- module.exports = function wiring(controllers, onConnent) {
29
+ module.exports = function wiring(controllers, onConnent, fileTransfer) {
30
30
  onConnent = onConnent || (() => { });
31
31
  return function webSocketHandler(socket, req) {
32
32
 
@@ -70,7 +70,8 @@ module.exports = function wiring(controllers, onConnent) {
70
70
  events: { onReceive, onSend, onError, onDisconnent },
71
71
  controllers,
72
72
  sharedValues,
73
- embedValues: embed
73
+ embedValues: embed,
74
+ fileTransfer // Pass file transfer manager
74
75
  }// END ape
75
76
  send = socketSend(ape)
76
77
  ape.send = send
@@ -92,3 +93,4 @@ module.exports = function wiring(controllers, onConnent) {
92
93
 
93
94
  } // END webSocketHandler
94
95
  } // END wiring
96
+
@@ -2,8 +2,95 @@ const messageHash = require('../../utils/messageHash')
2
2
  const { broadcast, online, getClients } = require('../lib/broadcast')
3
3
  const jss = require('../../utils/jss')
4
4
 
5
+ /**
6
+ * Find B/A tagged properties in data (indicating pending uploads)
7
+ * Returns array of { path, hash, tag }
8
+ */
9
+ function findUploadTags(obj, path = '') {
10
+ const uploads = []
11
+
12
+ if (obj === null || obj === undefined || typeof obj !== 'object') {
13
+ return uploads
14
+ }
15
+
16
+ if (Array.isArray(obj)) {
17
+ for (let i = 0; i < obj.length; i++) {
18
+ uploads.push(...findUploadTags(obj[i], path ? `${path}.${i}` : String(i)))
19
+ }
20
+ return uploads
21
+ }
22
+
23
+ for (const key of Object.keys(obj)) {
24
+ // Check for B or A tag (binary upload markers)
25
+ const bMatch = key.match(/^(.+)<!B>$/)
26
+ const aMatch = key.match(/^(.+)<!A>$/)
27
+
28
+ if (bMatch) {
29
+ uploads.push({
30
+ path: path ? `${path}.${bMatch[1]}` : bMatch[1],
31
+ hash: obj[key],
32
+ tag: 'B',
33
+ originalKey: key
34
+ })
35
+ } else if (aMatch) {
36
+ uploads.push({
37
+ path: path ? `${path}.${aMatch[1]}` : aMatch[1],
38
+ hash: obj[key],
39
+ tag: 'A',
40
+ originalKey: key
41
+ })
42
+ } else {
43
+ uploads.push(...findUploadTags(obj[key], path ? `${path}.${key}` : key))
44
+ }
45
+ }
46
+
47
+ return uploads
48
+ }
49
+
50
+ /**
51
+ * Clean upload tags from data (rename key<!B> to key)
52
+ */
53
+ function cleanUploadTags(obj) {
54
+ if (obj === null || obj === undefined || typeof obj !== 'object') {
55
+ return obj
56
+ }
57
+
58
+ if (Array.isArray(obj)) {
59
+ return obj.map(cleanUploadTags)
60
+ }
61
+
62
+ const cleaned = {}
63
+ for (const key of Object.keys(obj)) {
64
+ const bMatch = key.match(/^(.+)<!B>$/)
65
+ const aMatch = key.match(/^(.+)<!A>$/)
66
+
67
+ if (bMatch) {
68
+ cleaned[bMatch[1]] = obj[key] // Will be replaced with actual data
69
+ } else if (aMatch) {
70
+ cleaned[aMatch[1]] = obj[key] // Will be replaced with actual data
71
+ } else {
72
+ cleaned[key] = cleanUploadTags(obj[key])
73
+ }
74
+ }
75
+ return cleaned
76
+ }
77
+
78
+ /**
79
+ * Set value at nested path
80
+ */
81
+ function setValueAtPath(obj, path, value) {
82
+ const parts = path.split('.')
83
+ let current = obj
84
+
85
+ for (let i = 0; i < parts.length - 1; i++) {
86
+ current = current[parts[i]]
87
+ }
88
+
89
+ current[parts[parts.length - 1]] = value
90
+ }
91
+
5
92
  module.exports = function receiveHandler(ape) {
6
- const { send, checkReply, events, controllers, sharedValues, hostId, embedValues } = ape
93
+ const { send, checkReply, events, controllers, sharedValues, hostId, embedValues, fileTransfer } = ape
7
94
 
8
95
  // Build `this` context for controllers
9
96
  // Includes: client metadata + api-ape utilities
@@ -18,7 +105,7 @@ module.exports = function receiveHandler(ape) {
18
105
  hostId
19
106
  }
20
107
 
21
- return function onReceive(msg) {
108
+ return async function onReceive(msg) {
22
109
  // Convert Buffer to string - WebSocket messages may arrive as binary
23
110
  const msgString = typeof msg === 'string' ? msg : msg.toString('utf8');
24
111
  const queryId = messageHash(msgString);
@@ -31,6 +118,34 @@ module.exports = function receiveHandler(ape) {
31
118
  // Call onReceive hook - it should return a finish callback
32
119
  const onFinish = events.onReceive(queryId, data, type) || (() => { })
33
120
 
121
+ // Check for pending uploads (B/A tags)
122
+ let processedData = data
123
+ if (fileTransfer && data) {
124
+ const uploadTags = findUploadTags(data)
125
+
126
+ if (uploadTags.length > 0) {
127
+ console.log(`📤 Waiting for ${uploadTags.length} upload(s) for ${type}`)
128
+
129
+ // Clean the data object
130
+ processedData = cleanUploadTags(data)
131
+
132
+ // Wait for all uploads
133
+ try {
134
+ await Promise.all(uploadTags.map(async ({ path, hash }) => {
135
+ const uploadData = await fileTransfer.registerUpload(queryId, hash, hostId)
136
+ setValueAtPath(processedData, path, uploadData)
137
+ }))
138
+ } catch (uploadErr) {
139
+ console.error(`📤 Upload wait failed:`, uploadErr)
140
+ send(queryId, false, false, uploadErr)
141
+ if (typeof onFinish === 'function') {
142
+ onFinish(uploadErr, true)
143
+ }
144
+ return
145
+ }
146
+ }
147
+ }
148
+
34
149
  const result = new Promise((resolve, reject) => {
35
150
  try {
36
151
  const controller = controllers[type]
@@ -38,7 +153,7 @@ module.exports = function receiveHandler(ape) {
38
153
  throw `TypeError: "${type}" was not found`
39
154
  }
40
155
  checkReply(queryId, createdAt)
41
- resolve(controller.call(that, data))
156
+ resolve(controller.call(that, processedData))
42
157
  } catch (err) {
43
158
  reject(err)
44
159
  }
@@ -1,4 +1,5 @@
1
1
  const jss = require('../../utils/jss')
2
+ const { FileTransferManager } = require('../lib/fileTransfer')
2
3
 
3
4
  function checkSocketState(socket) {
4
5
  if (socket.readyState !== socket.OPEN) {
@@ -17,7 +18,88 @@ function checkSocketState(socket) {
17
18
  } // END if
18
19
  } // END checkSocketState
19
20
 
20
- module.exports = function sendHandler({ socket, events, hostId }) {
21
+ /**
22
+ * Check if value is binary data (Buffer, ArrayBuffer, or typed array)
23
+ */
24
+ function isBinaryData(value) {
25
+ if (value === null || value === undefined) return false
26
+ return Buffer.isBuffer(value) ||
27
+ value instanceof ArrayBuffer ||
28
+ ArrayBuffer.isView(value)
29
+ }
30
+
31
+ /**
32
+ * Detect content type from binary data
33
+ */
34
+ function detectContentType(data) {
35
+ // Could be enhanced with magic number detection
36
+ return 'application/octet-stream'
37
+ }
38
+
39
+ /**
40
+ * Process data object, replacing binary values with L-tagged hashes
41
+ * Returns { processedData, binaryEntries }
42
+ */
43
+ function processBinaryData(data, queryId, fileTransfer, hostId, path = '') {
44
+ if (data === null || data === undefined) {
45
+ return { processedData: data, binaryEntries: [] }
46
+ }
47
+
48
+ if (isBinaryData(data)) {
49
+ // This is binary data - register and return hash
50
+ const hash = FileTransferManager.generateHash(queryId, path || 'root')
51
+ const contentType = detectContentType(data)
52
+ fileTransfer.registerDownload(hash, data, contentType, hostId)
53
+
54
+ return {
55
+ processedData: { [`__ape_link__`]: hash },
56
+ binaryEntries: [{ path, hash }]
57
+ }
58
+ }
59
+
60
+ if (Array.isArray(data)) {
61
+ const processedArray = []
62
+ const allBinaryEntries = []
63
+
64
+ for (let i = 0; i < data.length; i++) {
65
+ const itemPath = path ? `${path}.${i}` : String(i)
66
+ const { processedData, binaryEntries } = processBinaryData(
67
+ data[i], queryId, fileTransfer, hostId, itemPath
68
+ )
69
+ processedArray.push(processedData)
70
+ allBinaryEntries.push(...binaryEntries)
71
+ }
72
+
73
+ return { processedData: processedArray, binaryEntries: allBinaryEntries }
74
+ }
75
+
76
+ if (typeof data === 'object') {
77
+ const processedObj = {}
78
+ const allBinaryEntries = []
79
+
80
+ for (const key of Object.keys(data)) {
81
+ const itemPath = path ? `${path}.${key}` : key
82
+ const { processedData, binaryEntries } = processBinaryData(
83
+ data[key], queryId, fileTransfer, hostId, itemPath
84
+ )
85
+
86
+ // If this was binary data, mark the key with <!L> tag
87
+ if (binaryEntries.length > 0 && processedData?.__ape_link__) {
88
+ processedObj[`${key}<!L>`] = processedData.__ape_link__
89
+ } else {
90
+ processedObj[key] = processedData
91
+ }
92
+ allBinaryEntries.push(...binaryEntries)
93
+ }
94
+
95
+ return { processedData: processedObj, binaryEntries: allBinaryEntries }
96
+ }
97
+
98
+ // Primitive value - return as-is
99
+ return { processedData: data, binaryEntries: [] }
100
+ }
101
+
102
+ module.exports = function sendHandler({ socket, events, hostId, fileTransfer }) {
21
103
 
22
104
  return function send(queryId, type, data, err) {
23
105
  if (!type && !queryId) {
@@ -43,11 +125,24 @@ module.exports = function sendHandler({ socket, events, hostId }) {
43
125
  }
44
126
  return;
45
127
  }
128
+
129
+ // Process binary data if fileTransfer is available
130
+ let processedData = data
131
+ if (fileTransfer && data && !err) {
132
+ const { processedData: processed, binaryEntries } = processBinaryData(
133
+ data, queryId || type, fileTransfer, hostId
134
+ )
135
+ processedData = processed
136
+ if (binaryEntries.length > 0) {
137
+ console.log(`📦 Registered ${binaryEntries.length} binary download(s) for ${queryId || type}`)
138
+ }
139
+ }
140
+
46
141
  if (err) {
47
142
  socket.send(jss.stringify({ err: err.message || err, type, queryId }))
48
143
  if (typeof onFinish === 'function') onFinish(err, true)
49
144
  } else {
50
- socket.send(jss.stringify({ data, type, queryId }))
145
+ socket.send(jss.stringify({ data: processedData, type, queryId }))
51
146
  if (typeof onFinish === 'function') onFinish(false, data)
52
147
  }
53
148