api-ape 2.0.0 → 2.2.2

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 (89) hide show
  1. package/README.md +203 -124
  2. package/client/README.md +37 -30
  3. package/client/browser.js +10 -8
  4. package/client/connectSocket.js +662 -381
  5. package/client/index.js +171 -0
  6. package/client/transports/streaming.js +240 -0
  7. package/dist/ape.js +2 -699
  8. package/dist/ape.js.map +7 -0
  9. package/dist/api-ape.min.js +2 -0
  10. package/dist/api-ape.min.js.map +7 -0
  11. package/index.d.ts +71 -18
  12. package/package.json +50 -15
  13. package/server/README.md +99 -13
  14. package/server/lib/broadcast.js +25 -8
  15. package/server/lib/bun.js +122 -0
  16. package/server/lib/longPolling.js +226 -0
  17. package/server/lib/main.js +381 -38
  18. package/server/lib/wiring.js +19 -12
  19. package/server/lib/ws/adapters/bun.js +225 -0
  20. package/server/lib/ws/adapters/deno.js +186 -0
  21. package/server/lib/ws/frames.js +217 -0
  22. package/server/lib/ws/index.js +15 -0
  23. package/server/lib/ws/server.js +109 -0
  24. package/server/lib/ws/socket.js +222 -0
  25. package/server/lib/wsProvider.js +135 -0
  26. package/server/security/origin.js +16 -4
  27. package/server/socket/receive.js +14 -1
  28. package/server/socket/send.js +6 -6
  29. package/server/utils/deepRequire.js +25 -10
  30. package/server/utils/parseUserAgent.js +286 -0
  31. package/example/Bun/README.md +0 -74
  32. package/example/Bun/api/message.ts +0 -11
  33. package/example/Bun/index.html +0 -76
  34. package/example/Bun/package.json +0 -9
  35. package/example/Bun/server.ts +0 -59
  36. package/example/Bun/styles.css +0 -128
  37. package/example/ExpressJs/README.md +0 -95
  38. package/example/ExpressJs/api/message.js +0 -11
  39. package/example/ExpressJs/backend.js +0 -39
  40. package/example/ExpressJs/index.html +0 -88
  41. package/example/ExpressJs/package-lock.json +0 -834
  42. package/example/ExpressJs/package.json +0 -10
  43. package/example/ExpressJs/styles.css +0 -128
  44. package/example/NextJs/.dockerignore +0 -29
  45. package/example/NextJs/Dockerfile +0 -52
  46. package/example/NextJs/Dockerfile.dev +0 -27
  47. package/example/NextJs/README.md +0 -113
  48. package/example/NextJs/ape/client.js +0 -66
  49. package/example/NextJs/ape/embed.js +0 -12
  50. package/example/NextJs/ape/index.js +0 -23
  51. package/example/NextJs/ape/logic/chat.js +0 -62
  52. package/example/NextJs/ape/onConnect.js +0 -69
  53. package/example/NextJs/ape/onDisconnect.js +0 -13
  54. package/example/NextJs/ape/onError.js +0 -9
  55. package/example/NextJs/ape/onReceive.js +0 -15
  56. package/example/NextJs/ape/onSend.js +0 -15
  57. package/example/NextJs/api/message.js +0 -44
  58. package/example/NextJs/docker-compose.yml +0 -22
  59. package/example/NextJs/next-env.d.ts +0 -5
  60. package/example/NextJs/next.config.js +0 -8
  61. package/example/NextJs/package-lock.json +0 -6400
  62. package/example/NextJs/package.json +0 -24
  63. package/example/NextJs/pages/Info.tsx +0 -153
  64. package/example/NextJs/pages/_app.tsx +0 -6
  65. package/example/NextJs/pages/index.tsx +0 -275
  66. package/example/NextJs/public/favicon.ico +0 -0
  67. package/example/NextJs/public/vercel.svg +0 -4
  68. package/example/NextJs/server.js +0 -36
  69. package/example/NextJs/styles/Chat.module.css +0 -448
  70. package/example/NextJs/styles/Home.module.css +0 -129
  71. package/example/NextJs/styles/globals.css +0 -26
  72. package/example/NextJs/tsconfig.json +0 -20
  73. package/example/README.md +0 -117
  74. package/example/Vite/README.md +0 -68
  75. package/example/Vite/ape/client.ts +0 -66
  76. package/example/Vite/ape/onConnect.ts +0 -52
  77. package/example/Vite/api/message.ts +0 -57
  78. package/example/Vite/index.html +0 -16
  79. package/example/Vite/package.json +0 -19
  80. package/example/Vite/server.ts +0 -62
  81. package/example/Vite/src/App.vue +0 -170
  82. package/example/Vite/src/components/Info.vue +0 -352
  83. package/example/Vite/src/main.ts +0 -5
  84. package/example/Vite/src/style.css +0 -200
  85. package/example/Vite/src/vite-env.d.ts +0 -7
  86. package/example/Vite/vite.config.ts +0 -20
  87. package/todo.md +0 -85
  88. package/utils/jss.test.js +0 -261
  89. package/utils/messageHash.test.js +0 -56
package/package.json CHANGED
@@ -1,24 +1,62 @@
1
1
  {
2
2
  "name": "api-ape",
3
- "version": "2.0.0",
4
- "description": "Remote procedure events",
3
+ "version": "2.2.2",
4
+ "description": "Remote Procedure Events (RPE) - A lightweight WebSocket framework for building real-time APIs. Call server functions from the browser like local methods with automatic reconnection, HTTP streaming fallback, and extended JSON encoding.",
5
5
  "main": "index.js",
6
+ "browser": "./client/index.js",
6
7
  "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "browser": "./client/index.js",
11
+ "default": "./index.js"
12
+ },
13
+ "./client": "./client/index.js",
14
+ "./client/*": "./client/*",
15
+ "./server": "./server/index.js",
16
+ "./server/*": "./server/*"
17
+ },
18
+ "files": [
19
+ "dist/",
20
+ "client/",
21
+ "server/",
22
+ "utils/",
23
+ "index.js",
24
+ "index.d.ts",
25
+ "!**/*.test.js"
26
+ ],
7
27
  "scripts": {
8
- "test": "jest --no-cache ",
9
- "test:go": "npm test -- --watch --coverage",
10
- "test:update": "npm test -- --updateSnapshot",
11
- "test:cover": "npm test -- --coverage --no-cache --detectOpenHandles",
12
- "test:watch": "npm test -- --watch --runInBand"
28
+ "prepare": "cp .hooks/* .git/hooks/ 2>/dev/null && chmod +x .git/hooks/* || true",
29
+ "test": "jest --no-cache",
30
+ "test:watch": "jest --watch --runInBand",
31
+ "test:cover": "jest --coverage --no-cache --detectOpenHandles",
32
+ "demo:express": "cd example/ExpressJs && npm install && npm start",
33
+ "demo:nextjs": "cd example/NextJs && docker-compose up --build",
34
+ "demo:vite": "cd example/Vite && npm install && npm run dev",
35
+ "demo:bun": "cd example/Bun && npm install && npm start",
36
+ "publish": "bash scripts/publish.sh"
13
37
  },
14
38
  "repository": {
15
39
  "type": "git",
16
40
  "url": "git+https://github.com/codemeasandwich/api-ape.git"
17
41
  },
18
42
  "keywords": [
19
- "rpc",
43
+ "websocket",
44
+ "realtime",
45
+ "CSRF",
20
46
  "api",
21
- "ape"
47
+ "remote-procedure-call",
48
+ "real-time",
49
+ "broadcast",
50
+ "socket",
51
+ "event-driven",
52
+ "http-streaming",
53
+ "long-polling",
54
+ "client-server",
55
+ "auto-reconnect",
56
+ "jjs",
57
+ "rpe",
58
+ "nodejs",
59
+ "library"
22
60
  ],
23
61
  "author": "brian shannon",
24
62
  "license": "MIT",
@@ -26,12 +64,9 @@
26
64
  "url": "https://github.com/codemeasandwich/api-ape/issues"
27
65
  },
28
66
  "homepage": "https://github.com/codemeasandwich/api-ape#readme",
29
- "dependencies": {
30
- "jest": "^29.3.1",
31
- "ua-parser-js": "^1.0.37",
32
- "ws": "^8.14.0"
33
- },
67
+ "dependencies": {},
34
68
  "devDependencies": {
35
- "esbuild": "^0.27.2"
69
+ "esbuild": "^0.27.2",
70
+ "jest": "^29.3.1"
36
71
  }
37
72
  }
package/server/README.md CHANGED
@@ -8,15 +8,26 @@ Express.js integration for WebSocket-based Remote Procedure Events (RPE).
8
8
  server/
9
9
  ├── index.js # Entry point (exports lib/main)
10
10
  ├── lib/
11
- │ ├── main.js # Express integration & setup
11
+ │ ├── main.js # HTTP server integration & setup
12
12
  │ ├── loader.js # Auto-loads controller files from folder
13
13
  │ ├── broadcast.js # Client tracking & broadcast utilities
14
14
  │ ├── fileTransfer.js # Binary file transfer manager
15
- └── wiring.js # WebSocket handler setup
15
+ ├── longPolling.js # HTTP streaming fallback handler
16
+ │ ├── wiring.js # WebSocket handler setup
17
+ │ ├── wsProvider.js # Runtime detection (Node 24+ native / polyfill)
18
+ │ └── ws/ # RFC 6455 WebSocket polyfill (zero dependencies)
19
+ │ ├── index.js # Module entry point
20
+ │ ├── frames.js # Frame encoding/decoding
21
+ │ ├── socket.js # WebSocket connection class
22
+ │ ├── server.js # WebSocketServer class
23
+ │ └── adapters/ # Runtime-specific adapters
24
+ │ ├── bun.js # Bun native WebSocket
25
+ │ └── deno.js # Deno native WebSocket
16
26
  ├── socket/
17
27
  │ ├── receive.js # Incoming message handler
18
28
  │ └── send.js # Outgoing message handler
19
29
  ├── security/
30
+ │ ├── origin.js # Origin verification (works with Express & raw Node.js)
20
31
  │ └── reply.js # Duplicate request protection
21
32
  └── utils/
22
33
  └── ... # Server utilities
@@ -29,12 +40,12 @@ npm i api-ape
29
40
  ```
30
41
 
31
42
  ```js
32
- const express = require('express')
43
+ const { createServer } = require('http')
33
44
  const ape = require('api-ape')
34
45
 
35
- const app = express()
46
+ const server = createServer()
36
47
 
37
- ape(app, {
48
+ ape(server, {
38
49
  where: 'api', // Controller directory
39
50
  onConnent: (socket, req, send) => ({
40
51
  embed: { userId: req.session?.userId },
@@ -42,12 +53,12 @@ ape(app, {
42
53
  })
43
54
  })
44
55
 
45
- app.listen(3000)
56
+ server.listen(3000)
46
57
  ```
47
58
 
48
59
  ## API
49
60
 
50
- ### `ape(app, options)`
61
+ ### `ape(server, options)`
51
62
 
52
63
  | Option | Type | Description |
53
64
  |--------|------|-------------|
@@ -74,8 +85,9 @@ ape(app, {
74
85
  | `this.broadcast(type, data)` | Send to ALL connected clients |
75
86
  | `this.broadcastOthers(type, data)` | Send to all EXCEPT the caller |
76
87
  | `this.online()` | Get count of connected clients |
77
- | `this.getClients()` | Get array of connected hostIds |
78
- | `this.hostId` | Unique ID of the calling client |
88
+ | `this.getClients()` | Get array of connected clientIds |
89
+ | `this.clientId` | Unique ID of the calling client (generated by api-ape) |
90
+ | `this.sessionId` | Session ID from cookie (set by outer framework, may be `null`) |
79
91
  | `this.req` | Original HTTP request |
80
92
  | `this.socket` | WebSocket instance |
81
93
  | `this.agent` | Parsed user-agent |
@@ -100,10 +112,22 @@ Drop JS files in your `where` directory:
100
112
 
101
113
  ```
102
114
  api/
103
- ├── hello.js → ape.hello(data)
104
- ├── users/
105
- ├── list.js → ape.users.list(data)
106
- └── create.js ape.users.create(data)
115
+ ├── hello.js → api.hello(data)
116
+ ├── users.js → api.users(data)
117
+ ├── posts/
118
+ ├── index.js api.posts(data) # index.js maps to parent folder
119
+ │ ├── list.js → api.posts.list(data)
120
+ │ └── create.js → api.posts.create(data)
121
+ ```
122
+
123
+ **Note**: Both `api/users.js` and `api/users/index.js` map to the same endpoint `api.users(data)`. Use `index.js` when you want to group related files in a folder.
124
+
125
+ **⚠️ Duplicate Detection**: If both files exist, api-ape will throw an error on startup:
126
+ ```
127
+ 🦍 Duplicate endpoint detected: "users"
128
+ - /users/index.js
129
+ - /users.js
130
+ Remove one of these files to fix this conflict.
107
131
  ```
108
132
 
109
133
  ## File Transfers
@@ -135,3 +159,65 @@ module.exports = function({ name, data }) {
135
159
 
136
160
  Binary data is transferred via `/api/ape/data/:hash` with session verification and HTTPS enforcement (localhost exempt).
137
161
 
162
+ ---
163
+
164
+ ## HTTP Streaming Endpoints
165
+
166
+ api-ape automatically provides HTTP streaming endpoints as a fallback when WebSockets are blocked:
167
+
168
+ ### GET `/api/ape/poll`
169
+
170
+ Long-lived HTTP streaming connection for receiving server messages.
171
+
172
+ - **Session**: Cookie-based (`apeClientId`)
173
+ - **Response**: Streaming JSON messages
174
+ - **Heartbeat**: Every 20 seconds
175
+ - **Auto-reconnect**: Client reconnects after 25 seconds
176
+
177
+ ### POST `/api/ape/poll`
178
+
179
+ Send messages to server when using HTTP streaming transport.
180
+
181
+ - **Session**: Cookie-based (`apeClientId`)
182
+ - **Body**: JJS-encoded message
183
+ - **Response**: JJS-encoded result
184
+
185
+ ### How It Works
186
+
187
+ 1. Client attempts WebSocket connection first
188
+ 2. On failure (firewall/proxy blocking), falls back to HTTP streaming
189
+ 3. Background WebSocket retry every 30 seconds
190
+ 4. Automatically upgrades back to WebSocket when available
191
+
192
+ The fallback is **completely transparent** to your controllers - they work identically with both transports.
193
+
194
+ ---
195
+
196
+ ## Zero-Dependency WebSocket
197
+
198
+ api-ape includes its own RFC 6455 WebSocket implementation with **zero npm dependencies**.
199
+
200
+ ### Runtime Detection
201
+
202
+ The server automatically detects and uses the best available WebSocket implementation:
203
+
204
+ 1. **Deno**: Uses native `Deno.upgradeWebSocket()` API
205
+ 2. **Bun**: Uses native `Bun.serve()` WebSocket handlers
206
+ 3. **Node.js 24+** (stable): Uses native `node:ws` module
207
+ 4. **Earlier Node.js**: Uses built-in RFC 6455 polyfill
208
+
209
+ ```javascript
210
+ // Automatic - no configuration needed
211
+ ape(server, { where: 'api' })
212
+ ```
213
+
214
+ ### Polyfill Features
215
+
216
+ The built-in polyfill implements:
217
+
218
+ - Full RFC 6455 handshake (SHA-1 + GUID)
219
+ - Text and binary frames
220
+ - Frame fragmentation
221
+ - Ping/pong heartbeats
222
+ - Proper close handshake
223
+ - Masking (client→server)
@@ -11,31 +11,48 @@ const connectedClients = new Set()
11
11
  */
12
12
  function addClient(clientInfo) {
13
13
  connectedClients.add(clientInfo)
14
+ console.log(`🟢 Client added: ${clientInfo.clientId} (total: ${connectedClients.size})`)
14
15
  }
15
16
 
16
17
  /**
17
18
  * Remove a client from the connected set
19
+ * Accepts either the client object or { clientId } for lookup
18
20
  */
19
21
  function removeClient(clientInfo) {
20
- connectedClients.delete(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
28
+ }
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
+ }
36
+ }
37
+ console.log(`⚠️ Client not found for removal: ${clientInfo.clientId} (total: ${connectedClients.size})`)
21
38
  }
22
39
 
23
40
  /**
24
41
  * Broadcast to all connected clients
25
42
  * @param {string} type - Message type
26
43
  * @param {any} data - Data to send
27
- * @param {string} [excludeHostId] - Optional hostId to exclude (e.g., sender)
44
+ * @param {string} [excludeClientId] - Optional clientId to exclude (e.g., sender)
28
45
  */
29
- function broadcast(type, data, excludeHostId) {
30
- console.log(`📢 Broadcasting "${type}" to ${connectedClients.size} clients`, excludeHostId ? `(excluding ${excludeHostId})` : '')
46
+ function broadcast(type, data, excludeClientId) {
47
+ console.log(`📢 Broadcasting "${type}" to ${connectedClients.size} clients`, excludeClientId ? `(excluding ${excludeClientId})` : '')
31
48
  connectedClients.forEach(client => {
32
- if (excludeHostId && client.hostId === excludeHostId) {
49
+ if (excludeClientId && client.clientId === excludeClientId) {
33
50
  return // Skip excluded client
34
51
  }
35
52
  try {
36
53
  client.send(false, type, data, false)
37
54
  } catch (e) {
38
- console.error(`📢 Broadcast failed to ${client.hostId}:`, e.message)
55
+ console.error(`📢 Broadcast failed to ${client.clientId}:`, e.message)
39
56
  }
40
57
  })
41
58
  }
@@ -48,10 +65,10 @@ function online() {
48
65
  }
49
66
 
50
67
  /**
51
- * Get all connected client hostIds
68
+ * Get all connected client clientIds
52
69
  */
53
70
  function getClients() {
54
- return Array.from(connectedClients).map(c => c.hostId)
71
+ return Array.from(connectedClients).map(c => c.clientId)
55
72
  }
56
73
 
57
74
  module.exports = {
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Bun-specific api-ape integration
3
+ * Returns handlers for use with Bun.serve()
4
+ *
5
+ * Usage:
6
+ * ```ts
7
+ * import { apeBun } from 'api-ape/bun'
8
+ *
9
+ * const ape = apeBun({ where: 'api', onConnent: ... })
10
+ *
11
+ * Bun.serve({
12
+ * port: 3000,
13
+ * fetch: ape.fetch,
14
+ * websocket: ape.websocket
15
+ * })
16
+ * ```
17
+ */
18
+
19
+ const loader = require('./loader')
20
+ const wiring = require('./wiring')
21
+ const path = require('path')
22
+ const fs = require('fs')
23
+ const { getFileTransferManager } = require('./fileTransfer')
24
+ const { BunWebSocket, BunWebSocketServer } = require('./ws/adapters/bun')
25
+
26
+ /**
27
+ * Create api-ape handlers for Bun.serve()
28
+ * @param {{ where: string, onConnent?: Function, fileTransferOptions?: Object }} options
29
+ */
30
+ function apeBun({ where, onConnent, fileTransferOptions }) {
31
+ const controllers = loader(where)
32
+ const fileTransfer = getFileTransferManager(fileTransferOptions)
33
+ const wss = new BunWebSocketServer({ noServer: true })
34
+
35
+ const wsPath = `/${where}/ape`
36
+ const wiringHandler = wiring(controllers, onConnent, fileTransfer)
37
+
38
+ // Handle connections
39
+ wss.on('connection', wiringHandler)
40
+
41
+ /**
42
+ * Bun fetch handler - handles HTTP requests and WebSocket upgrades
43
+ */
44
+ function fetch(req, server) {
45
+ const url = new URL(req.url)
46
+ const pathname = url.pathname
47
+
48
+ // WebSocket upgrade
49
+ if (pathname === wsPath) {
50
+ const upgrade = req.headers.get('upgrade')
51
+ if (upgrade?.toLowerCase() === 'websocket') {
52
+ // Use Bun's native upgrade
53
+ const success = server.upgrade(req, {
54
+ data: { req }
55
+ })
56
+ if (success) {
57
+ return undefined // Bun handles the response
58
+ }
59
+ return new Response('WebSocket upgrade failed', { status: 500 })
60
+ }
61
+ }
62
+
63
+ // Serve client bundle
64
+ if (pathname === `/${where}/ape.js`) {
65
+ try {
66
+ const filePath = path.join(__dirname, '../../dist/ape.js')
67
+ const data = fs.readFileSync(filePath)
68
+ return new Response(data, {
69
+ headers: { 'Content-Type': 'application/javascript' }
70
+ })
71
+ } catch {
72
+ return new Response('Client bundle not found', { status: 500 })
73
+ }
74
+ }
75
+
76
+ // Not an api-ape route
77
+ return null
78
+ }
79
+
80
+ /**
81
+ * Bun websocket handlers
82
+ */
83
+ const websocket = {
84
+ open(ws) {
85
+ const wrapper = new BunWebSocket(ws)
86
+ wss._clients.set(ws, wrapper)
87
+
88
+ const { req } = ws.data || {}
89
+ wiringHandler(wrapper, req)
90
+ },
91
+
92
+ message(ws, message) {
93
+ const wrapper = wss._clients.get(ws)
94
+ if (wrapper) {
95
+ wrapper._onMessage(message)
96
+ }
97
+ },
98
+
99
+ close(ws, code, reason) {
100
+ const wrapper = wss._clients.get(ws)
101
+ if (wrapper) {
102
+ wrapper._onClose(code, reason)
103
+ wss._clients.delete(ws)
104
+ }
105
+ },
106
+
107
+ error(ws, error) {
108
+ const wrapper = wss._clients.get(ws)
109
+ if (wrapper) {
110
+ wrapper._onError(error)
111
+ }
112
+ }
113
+ }
114
+
115
+ return {
116
+ fetch,
117
+ websocket,
118
+ wss
119
+ }
120
+ }
121
+
122
+ module.exports = { apeBun }
@@ -0,0 +1,226 @@
1
+ const { addClient, removeClient, broadcast } = require('./broadcast')
2
+ const makeid = require('../utils/genId')
3
+ const jss = require('../../utils/jss')
4
+
5
+ // Active streaming connections: clientId -> { res, messageQueue, heartbeatTimer }
6
+ const streamClients = new Map()
7
+
8
+ // Pending message handlers for POST requests: queryId -> { resolve, reject, timer }
9
+ const pendingRequests = new Map()
10
+
11
+ /**
12
+ * Set apeClientId cookie if not present
13
+ */
14
+ function ensureClientId(req, res) {
15
+ const cookies = req.headers.cookie || ''
16
+ const match = cookies.match(/(?:^|;\s*)apeClientId=([^;]*)/)
17
+
18
+ if (match) {
19
+ return match[1]
20
+ }
21
+
22
+ // Generate new clientId and set cookie
23
+ const clientId = makeid(20)
24
+ res.setHeader('Set-Cookie', `apeClientId=${clientId}; Path=/; HttpOnly; SameSite=Strict`)
25
+ return clientId
26
+ }
27
+
28
+ /**
29
+ * Get clientId from cookie
30
+ */
31
+ function getClientId(req) {
32
+ const cookies = req.headers.cookie || ''
33
+ const match = cookies.match(/(?:^|;\s*)apeClientId=([^;]*)/)
34
+ return match ? match[1] : null
35
+ }
36
+
37
+ /**
38
+ * Send JSON response helper
39
+ */
40
+ function sendJson(res, statusCode, data) {
41
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' })
42
+ res.end(JSON.stringify(data))
43
+ }
44
+
45
+ /**
46
+ * Create long polling handler
47
+ */
48
+ function createLongPollingHandler(controllers, onConnent, fileTransfer) {
49
+
50
+ /**
51
+ * Handle GET /api/ape/poll - Streaming receive
52
+ * Keeps connection open and writes JSON messages as they arrive
53
+ */
54
+ function handleStreamGet(req, res) {
55
+ const clientId = ensureClientId(req, res)
56
+
57
+ // Set up streaming response headers
58
+ res.writeHead(200, {
59
+ 'Content-Type': 'application/json',
60
+ 'Cache-Control': 'no-cache',
61
+ 'Connection': 'keep-alive',
62
+ 'X-Accel-Buffering': 'no' // Disable nginx buffering
63
+ })
64
+
65
+ // Create message queue for this client
66
+ const clientState = {
67
+ res,
68
+ messageQueue: [],
69
+ heartbeatTimer: null,
70
+ isActive: true
71
+ }
72
+
73
+ // Send function for this streaming client
74
+ const send = (type, data, err) => {
75
+ if (!clientState.isActive) return
76
+
77
+ const message = jss.stringify({ type, data, err: err || undefined })
78
+ try {
79
+ res.write(message)
80
+ } catch (e) {
81
+ cleanup()
82
+ }
83
+ }
84
+ send.toString = () => clientId
85
+
86
+ // Clean up on close
87
+ const cleanup = () => {
88
+ if (!clientState.isActive) return
89
+ clientState.isActive = false
90
+
91
+ if (clientState.heartbeatTimer) {
92
+ clearInterval(clientState.heartbeatTimer)
93
+ }
94
+
95
+ streamClients.delete(clientId)
96
+ removeClient({ clientId })
97
+
98
+ // Notify disconnect handler if registered
99
+ if (clientState.onDisconnect) {
100
+ clientState.onDisconnect()
101
+ }
102
+ }
103
+
104
+ req.on('close', cleanup)
105
+ req.on('error', cleanup)
106
+ res.on('error', cleanup)
107
+
108
+ // Heartbeat to keep connection alive (every 20s)
109
+ clientState.heartbeatTimer = setInterval(() => {
110
+ if (!clientState.isActive) return
111
+ try {
112
+ // Send heartbeat as empty comment (client ignores)
113
+ res.write('{"type":"__heartbeat__"}')
114
+ } catch (e) {
115
+ cleanup()
116
+ }
117
+ }, 20000)
118
+
119
+ // Register client for broadcasts
120
+ const clientInfo = { clientId, send }
121
+ addClient(clientInfo)
122
+ streamClients.set(clientId, clientState)
123
+
124
+ // Call onConnent hook if provided
125
+ if (onConnent) {
126
+ Promise.resolve(onConnent(null, req, send))
127
+ .then(handlers => {
128
+ if (handlers) {
129
+ if (handlers.onDisconnent) {
130
+ clientState.onDisconnect = handlers.onDisconnent
131
+ }
132
+ if (handlers.embed) {
133
+ clientState.embed = handlers.embed
134
+ }
135
+ }
136
+ })
137
+ .catch(err => {
138
+ console.error('onConnent error:', err)
139
+ })
140
+ }
141
+
142
+ // Close after 25 seconds (before typical proxy timeout)
143
+ // Client will immediately reconnect
144
+ setTimeout(() => {
145
+ cleanup()
146
+ try {
147
+ res.end()
148
+ } catch (e) { }
149
+ }, 25000)
150
+ }
151
+
152
+ /**
153
+ * Handle POST /api/ape/poll - Send messages
154
+ * Process message through controllers, return response
155
+ */
156
+ function handleStreamPost(req, res, controllers) {
157
+ const clientId = getClientId(req)
158
+
159
+ if (!clientId) {
160
+ return sendJson(res, 401, { error: 'Missing session. GET /api/ape/poll first.' })
161
+ }
162
+
163
+ // Collect body
164
+ const chunks = []
165
+ req.on('data', chunk => chunks.push(chunk))
166
+ req.on('end', async () => {
167
+ try {
168
+ const body = Buffer.concat(chunks).toString('utf8')
169
+ const { type: rawType, data, createdAt } = jss.parse(body)
170
+
171
+ // Normalize type
172
+ const type = rawType.replace(/^\//, '').toLowerCase()
173
+
174
+ // Find controller
175
+ const controller = controllers[type]
176
+ if (!controller) {
177
+ return sendJson(res, 404, { error: `Controller "${type}" not found` })
178
+ }
179
+
180
+ // Get client state for embed values
181
+ const clientState = streamClients.get(clientId)
182
+ const embedValues = clientState?.embed || {}
183
+
184
+ // Extract sessionId from cookies (set by outer framework)
185
+ const sessionIdMatch = (req.headers.cookie || '').match(/(?:^|;\s*)sessionId=([^;]*)/)
186
+ const sessionId = sessionIdMatch ? sessionIdMatch[1] : null
187
+
188
+ // Build controller context
189
+ const context = {
190
+ ...embedValues,
191
+ clientId,
192
+ sessionId, // Session ID from cookie (set by outer framework)
193
+ req,
194
+ broadcast: (t, d) => broadcast(t, d),
195
+ broadcastOthers: (t, d) => broadcast(t, d, clientId),
196
+ online: () => streamClients.size,
197
+ getClients: () => Array.from(streamClients.keys())
198
+ }
199
+
200
+ // Execute controller
201
+ const result = await controller.call(context, data)
202
+
203
+ // Send response
204
+ const responsePayload = { data: result }
205
+ res.writeHead(200, { 'Content-Type': 'application/json' })
206
+ res.end(jss.stringify(responsePayload))
207
+
208
+ } catch (err) {
209
+ const errorMessage = err.message || String(err)
210
+ sendJson(res, 500, { error: errorMessage })
211
+ }
212
+ })
213
+
214
+ req.on('error', (err) => {
215
+ sendJson(res, 500, { error: err.message })
216
+ })
217
+ }
218
+
219
+ return {
220
+ handleStreamGet,
221
+ handleStreamPost,
222
+ getStreamClients: () => streamClients
223
+ }
224
+ }
225
+
226
+ module.exports = { createLongPollingHandler, getClientId, ensureClientId }