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.
- package/README.md +203 -124
- package/client/README.md +37 -30
- package/client/browser.js +10 -8
- package/client/connectSocket.js +662 -381
- package/client/index.js +171 -0
- package/client/transports/streaming.js +240 -0
- package/dist/ape.js +2 -699
- package/dist/ape.js.map +7 -0
- package/dist/api-ape.min.js +2 -0
- package/dist/api-ape.min.js.map +7 -0
- package/index.d.ts +71 -18
- package/package.json +50 -15
- package/server/README.md +99 -13
- package/server/lib/broadcast.js +25 -8
- package/server/lib/bun.js +122 -0
- package/server/lib/longPolling.js +226 -0
- package/server/lib/main.js +381 -38
- package/server/lib/wiring.js +19 -12
- package/server/lib/ws/adapters/bun.js +225 -0
- package/server/lib/ws/adapters/deno.js +186 -0
- package/server/lib/ws/frames.js +217 -0
- package/server/lib/ws/index.js +15 -0
- package/server/lib/ws/server.js +109 -0
- package/server/lib/ws/socket.js +222 -0
- package/server/lib/wsProvider.js +135 -0
- package/server/security/origin.js +16 -4
- package/server/socket/receive.js +14 -1
- package/server/socket/send.js +6 -6
- package/server/utils/deepRequire.js +25 -10
- package/server/utils/parseUserAgent.js +286 -0
- package/example/Bun/README.md +0 -74
- package/example/Bun/api/message.ts +0 -11
- package/example/Bun/index.html +0 -76
- package/example/Bun/package.json +0 -9
- package/example/Bun/server.ts +0 -59
- package/example/Bun/styles.css +0 -128
- package/example/ExpressJs/README.md +0 -95
- package/example/ExpressJs/api/message.js +0 -11
- package/example/ExpressJs/backend.js +0 -39
- package/example/ExpressJs/index.html +0 -88
- package/example/ExpressJs/package-lock.json +0 -834
- package/example/ExpressJs/package.json +0 -10
- package/example/ExpressJs/styles.css +0 -128
- package/example/NextJs/.dockerignore +0 -29
- package/example/NextJs/Dockerfile +0 -52
- package/example/NextJs/Dockerfile.dev +0 -27
- package/example/NextJs/README.md +0 -113
- package/example/NextJs/ape/client.js +0 -66
- package/example/NextJs/ape/embed.js +0 -12
- package/example/NextJs/ape/index.js +0 -23
- package/example/NextJs/ape/logic/chat.js +0 -62
- package/example/NextJs/ape/onConnect.js +0 -69
- package/example/NextJs/ape/onDisconnect.js +0 -13
- package/example/NextJs/ape/onError.js +0 -9
- package/example/NextJs/ape/onReceive.js +0 -15
- package/example/NextJs/ape/onSend.js +0 -15
- package/example/NextJs/api/message.js +0 -44
- package/example/NextJs/docker-compose.yml +0 -22
- package/example/NextJs/next-env.d.ts +0 -5
- package/example/NextJs/next.config.js +0 -8
- package/example/NextJs/package-lock.json +0 -6400
- package/example/NextJs/package.json +0 -24
- package/example/NextJs/pages/Info.tsx +0 -153
- package/example/NextJs/pages/_app.tsx +0 -6
- package/example/NextJs/pages/index.tsx +0 -275
- package/example/NextJs/public/favicon.ico +0 -0
- package/example/NextJs/public/vercel.svg +0 -4
- package/example/NextJs/server.js +0 -36
- package/example/NextJs/styles/Chat.module.css +0 -448
- package/example/NextJs/styles/Home.module.css +0 -129
- package/example/NextJs/styles/globals.css +0 -26
- package/example/NextJs/tsconfig.json +0 -20
- package/example/README.md +0 -117
- package/example/Vite/README.md +0 -68
- package/example/Vite/ape/client.ts +0 -66
- package/example/Vite/ape/onConnect.ts +0 -52
- package/example/Vite/api/message.ts +0 -57
- package/example/Vite/index.html +0 -16
- package/example/Vite/package.json +0 -19
- package/example/Vite/server.ts +0 -62
- package/example/Vite/src/App.vue +0 -170
- package/example/Vite/src/components/Info.vue +0 -352
- package/example/Vite/src/main.ts +0 -5
- package/example/Vite/src/style.css +0 -200
- package/example/Vite/src/vite-env.d.ts +0 -7
- package/example/Vite/vite.config.ts +0 -20
- package/todo.md +0 -85
- package/utils/jss.test.js +0 -261
- 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.
|
|
4
|
-
"description": "Remote
|
|
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
|
-
"
|
|
9
|
-
"test
|
|
10
|
-
"test:
|
|
11
|
-
"test:cover": "
|
|
12
|
-
"
|
|
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
|
-
"
|
|
43
|
+
"websocket",
|
|
44
|
+
"realtime",
|
|
45
|
+
"CSRF",
|
|
20
46
|
"api",
|
|
21
|
-
"
|
|
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 #
|
|
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
|
-
│
|
|
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
|
|
43
|
+
const { createServer } = require('http')
|
|
33
44
|
const ape = require('api-ape')
|
|
34
45
|
|
|
35
|
-
const
|
|
46
|
+
const server = createServer()
|
|
36
47
|
|
|
37
|
-
ape(
|
|
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
|
-
|
|
56
|
+
server.listen(3000)
|
|
46
57
|
```
|
|
47
58
|
|
|
48
59
|
## API
|
|
49
60
|
|
|
50
|
-
### `ape(
|
|
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
|
|
78
|
-
| `this.
|
|
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 →
|
|
104
|
-
├── users
|
|
105
|
-
|
|
106
|
-
│
|
|
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)
|
package/server/lib/broadcast.js
CHANGED
|
@@ -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.
|
|
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} [
|
|
44
|
+
* @param {string} [excludeClientId] - Optional clientId to exclude (e.g., sender)
|
|
28
45
|
*/
|
|
29
|
-
function broadcast(type, data,
|
|
30
|
-
console.log(`📢 Broadcasting "${type}" to ${connectedClients.size} clients`,
|
|
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 (
|
|
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.
|
|
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
|
|
68
|
+
* Get all connected client clientIds
|
|
52
69
|
*/
|
|
53
70
|
function getClients() {
|
|
54
|
-
return Array.from(connectedClients).map(c => c.
|
|
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 }
|