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.
- package/README.md +63 -16
- package/client/README.md +32 -0
- package/client/browser.js +7 -1
- package/client/connectSocket.js +323 -8
- package/dist/ape.js +289 -44
- package/example/Bun/README.md +74 -0
- package/example/Bun/api/message.ts +11 -0
- package/example/Bun/index.html +76 -0
- package/example/Bun/package.json +9 -0
- package/example/Bun/server.ts +59 -0
- package/example/Bun/styles.css +128 -0
- package/example/ExpressJs/README.md +5 -7
- package/example/ExpressJs/backend.js +23 -21
- package/example/NextJs/ape/client.js +3 -3
- package/example/NextJs/ape/onConnect.js +5 -5
- package/example/NextJs/package-lock.json +1353 -60
- package/example/NextJs/package.json +0 -1
- package/example/NextJs/pages/index.tsx +21 -10
- package/example/NextJs/server.js +7 -11
- package/example/README.md +51 -0
- package/example/Vite/README.md +68 -0
- package/example/Vite/ape/client.ts +66 -0
- package/example/Vite/ape/onConnect.ts +52 -0
- package/example/Vite/api/message.ts +57 -0
- package/example/Vite/index.html +16 -0
- package/example/Vite/package.json +19 -0
- package/example/Vite/server.ts +62 -0
- package/example/Vite/src/App.vue +170 -0
- package/example/Vite/src/components/Info.vue +352 -0
- package/example/Vite/src/main.ts +5 -0
- package/example/Vite/src/style.css +200 -0
- package/example/Vite/src/vite-env.d.ts +7 -0
- package/example/Vite/vite.config.ts +20 -0
- package/index.d.ts +31 -3
- package/package.json +2 -3
- package/server/README.md +44 -0
- package/server/index.js +10 -2
- package/server/lib/fileTransfer.js +247 -0
- package/server/lib/main.js +172 -9
- package/server/lib/wiring.js +4 -2
- package/server/socket/receive.js +118 -3
- package/server/socket/send.js +97 -2
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
* {
|
|
2
|
+
box-sizing: border-box;
|
|
3
|
+
margin: 0;
|
|
4
|
+
padding: 0;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.container {
|
|
8
|
+
min-height: 100vh;
|
|
9
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
|
10
|
+
color: #fff;
|
|
11
|
+
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.main {
|
|
15
|
+
max-width: 600px;
|
|
16
|
+
margin: 0 auto;
|
|
17
|
+
padding: 2rem;
|
|
18
|
+
width: 100%;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.title {
|
|
22
|
+
font-size: 2.5rem;
|
|
23
|
+
text-align: center;
|
|
24
|
+
margin-bottom: 0.5rem;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.gradient {
|
|
28
|
+
background: linear-gradient(90deg, #00d2ff, #3a7bd5);
|
|
29
|
+
-webkit-background-clip: text;
|
|
30
|
+
-webkit-text-fill-color: transparent;
|
|
31
|
+
background-clip: text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.subtitle {
|
|
35
|
+
text-align: center;
|
|
36
|
+
color: #0f0;
|
|
37
|
+
margin-bottom: 2rem;
|
|
38
|
+
font-weight: bold;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.join-form {
|
|
42
|
+
display: flex;
|
|
43
|
+
gap: 1rem;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.input {
|
|
48
|
+
padding: 1rem 1.5rem;
|
|
49
|
+
font-size: 1rem;
|
|
50
|
+
border: none;
|
|
51
|
+
border-radius: 50px;
|
|
52
|
+
background: rgba(255, 255, 255, 0.1);
|
|
53
|
+
color: #fff;
|
|
54
|
+
outline: none;
|
|
55
|
+
width: 250px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.input::placeholder {
|
|
59
|
+
color: rgba(255, 255, 255, 0.5);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.button {
|
|
63
|
+
padding: 1rem 2rem;
|
|
64
|
+
font-size: 1rem;
|
|
65
|
+
border: none;
|
|
66
|
+
border-radius: 50px;
|
|
67
|
+
background: linear-gradient(90deg, #00d2ff, #3a7bd5);
|
|
68
|
+
color: #fff;
|
|
69
|
+
cursor: pointer;
|
|
70
|
+
font-weight: bold;
|
|
71
|
+
transition: opacity 0.2s;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.button:hover {
|
|
75
|
+
opacity: 0.9;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.button:disabled {
|
|
79
|
+
opacity: 0.5;
|
|
80
|
+
cursor: not-allowed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.chat-container {
|
|
84
|
+
background: rgba(255, 255, 255, 0.05);
|
|
85
|
+
border-radius: 20px;
|
|
86
|
+
overflow: hidden;
|
|
87
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.header {
|
|
91
|
+
display: flex;
|
|
92
|
+
justify-content: space-between;
|
|
93
|
+
padding: 1rem 1.5rem;
|
|
94
|
+
background: rgba(255, 255, 255, 0.1);
|
|
95
|
+
font-weight: bold;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.user-count {
|
|
99
|
+
font-size: 0.85rem;
|
|
100
|
+
color: #0f0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.messages {
|
|
104
|
+
height: 350px;
|
|
105
|
+
overflow-y: auto;
|
|
106
|
+
padding: 1rem;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.messages::-webkit-scrollbar {
|
|
110
|
+
width: 6px;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.messages::-webkit-scrollbar-track {
|
|
114
|
+
background: rgba(255, 255, 255, 0.05);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.messages::-webkit-scrollbar-thumb {
|
|
118
|
+
background: rgba(255, 255, 255, 0.2);
|
|
119
|
+
border-radius: 3px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.empty-state {
|
|
123
|
+
text-align: center;
|
|
124
|
+
color: #666;
|
|
125
|
+
margin-top: 140px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.message {
|
|
129
|
+
display: flex;
|
|
130
|
+
flex-direction: column;
|
|
131
|
+
gap: 0.25rem;
|
|
132
|
+
padding: 0.75rem 1rem;
|
|
133
|
+
margin-bottom: 0.5rem;
|
|
134
|
+
background: rgba(255, 255, 255, 0.05);
|
|
135
|
+
border-radius: 12px;
|
|
136
|
+
border-left: 3px solid #3a7bd5;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.my-message {
|
|
140
|
+
background: rgba(0, 210, 255, 0.15);
|
|
141
|
+
border-left-color: #00d2ff;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.message .username {
|
|
145
|
+
color: #00d2ff;
|
|
146
|
+
font-size: 0.85rem;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.message .time {
|
|
150
|
+
color: #666;
|
|
151
|
+
font-size: 0.7rem;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.input-form {
|
|
155
|
+
display: flex;
|
|
156
|
+
gap: 0.5rem;
|
|
157
|
+
padding: 1rem;
|
|
158
|
+
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.message-input {
|
|
162
|
+
flex: 1;
|
|
163
|
+
padding: 0.75rem 1rem;
|
|
164
|
+
font-size: 1rem;
|
|
165
|
+
border: none;
|
|
166
|
+
border-radius: 50px;
|
|
167
|
+
background: rgba(255, 255, 255, 0.1);
|
|
168
|
+
color: #fff;
|
|
169
|
+
outline: none;
|
|
170
|
+
transition: background 0.2s;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.message-input::placeholder {
|
|
174
|
+
color: rgba(255, 255, 255, 0.5);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.message-input:focus {
|
|
178
|
+
background: rgba(255, 255, 255, 0.15);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.send-button {
|
|
182
|
+
padding: 0.75rem 1.5rem;
|
|
183
|
+
font-size: 1rem;
|
|
184
|
+
border: none;
|
|
185
|
+
border-radius: 50px;
|
|
186
|
+
background: #3a7bd5;
|
|
187
|
+
color: #fff;
|
|
188
|
+
cursor: pointer;
|
|
189
|
+
font-weight: bold;
|
|
190
|
+
transition: opacity 0.2s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.send-button:hover {
|
|
194
|
+
opacity: 0.9;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.send-button:disabled {
|
|
198
|
+
opacity: 0.5;
|
|
199
|
+
cursor: not-allowed;
|
|
200
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import vue from '@vitejs/plugin-vue'
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
plugins: [vue()],
|
|
6
|
+
server: {
|
|
7
|
+
port: 5173,
|
|
8
|
+
proxy: {
|
|
9
|
+
'/api': {
|
|
10
|
+
target: 'http://localhost:3000',
|
|
11
|
+
ws: true,
|
|
12
|
+
changeOrigin: true
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
build: {
|
|
17
|
+
outDir: 'dist',
|
|
18
|
+
emptyOutDir: true
|
|
19
|
+
}
|
|
20
|
+
})
|
package/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Type definitions for api-ape
|
|
2
2
|
// Project: https://github.com/codemeasandwich/api-ape
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { Server as HttpServer } from 'http'
|
|
5
5
|
import { WebSocket } from 'ws'
|
|
6
6
|
import { IncomingMessage } from 'http'
|
|
7
7
|
|
|
@@ -94,9 +94,18 @@ export interface ApeServerOptions {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
|
-
* Initialize api-ape on
|
|
97
|
+
* Initialize api-ape on a Node.js HTTP/HTTPS server
|
|
98
98
|
*/
|
|
99
|
-
declare function ape(
|
|
99
|
+
declare function ape(server: HttpServer, options: ApeServerOptions): void
|
|
100
|
+
|
|
101
|
+
declare namespace ape {
|
|
102
|
+
/** Broadcast to all connected clients */
|
|
103
|
+
export function broadcast(type: string, data: any, excludeHostId?: string): void
|
|
104
|
+
/** Get count of connected clients */
|
|
105
|
+
export function online(): number
|
|
106
|
+
/** Get all connected client hostIds */
|
|
107
|
+
export function getClients(): string[]
|
|
108
|
+
}
|
|
100
109
|
|
|
101
110
|
export default ape
|
|
102
111
|
|
|
@@ -104,6 +113,21 @@ export default ape
|
|
|
104
113
|
// CLIENT TYPES
|
|
105
114
|
// =============================================================================
|
|
106
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Connection state enum values
|
|
118
|
+
*/
|
|
119
|
+
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'closing'
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Connection state enum object
|
|
123
|
+
*/
|
|
124
|
+
export declare const ConnectionState: {
|
|
125
|
+
Disconnected: 'disconnected'
|
|
126
|
+
Connecting: 'connecting'
|
|
127
|
+
Connected: 'connected'
|
|
128
|
+
Closing: 'closing'
|
|
129
|
+
}
|
|
130
|
+
|
|
107
131
|
/**
|
|
108
132
|
* Message received from server
|
|
109
133
|
*/
|
|
@@ -140,6 +164,8 @@ export type SetOnReceiver = {
|
|
|
140
164
|
export interface ApeClient {
|
|
141
165
|
sender: ApeSender
|
|
142
166
|
setOnReciver: SetOnReceiver
|
|
167
|
+
/** Subscribe to connection state changes. Returns unsubscribe function. */
|
|
168
|
+
onConnectionChange: (handler: (state: ConnectionState) => void) => () => void
|
|
143
169
|
}
|
|
144
170
|
|
|
145
171
|
/**
|
|
@@ -161,6 +187,8 @@ export interface ConnectSocket {
|
|
|
161
187
|
configure(options: ApeClientConfig): void
|
|
162
188
|
/** Enable auto-reconnection on disconnect */
|
|
163
189
|
autoReconnect(): void
|
|
190
|
+
/** Connection state enum */
|
|
191
|
+
ConnectionState: typeof ConnectionState
|
|
164
192
|
}
|
|
165
193
|
|
|
166
194
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "api-ape",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Remote procedure events",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
},
|
|
28
28
|
"homepage": "https://github.com/codemeasandwich/api-ape#readme",
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"express-ws": "^5.0.2",
|
|
31
30
|
"jest": "^29.3.1",
|
|
32
31
|
"ua-parser-js": "^1.0.37",
|
|
33
32
|
"ws": "^8.14.0"
|
|
@@ -35,4 +34,4 @@
|
|
|
35
34
|
"devDependencies": {
|
|
36
35
|
"esbuild": "^0.27.2"
|
|
37
36
|
}
|
|
38
|
-
}
|
|
37
|
+
}
|
package/server/README.md
CHANGED
|
@@ -11,6 +11,7 @@ server/
|
|
|
11
11
|
│ ├── main.js # Express integration & setup
|
|
12
12
|
│ ├── loader.js # Auto-loads controller files from folder
|
|
13
13
|
│ ├── broadcast.js # Client tracking & broadcast utilities
|
|
14
|
+
│ ├── fileTransfer.js # Binary file transfer manager
|
|
14
15
|
│ └── wiring.js # WebSocket handler setup
|
|
15
16
|
├── socket/
|
|
16
17
|
│ ├── receive.js # Incoming message handler
|
|
@@ -52,6 +53,19 @@ app.listen(3000)
|
|
|
52
53
|
|--------|------|-------------|
|
|
53
54
|
| `where` | `string` | Directory containing controller files |
|
|
54
55
|
| `onConnent` | `function` | Connection lifecycle hook |
|
|
56
|
+
| `fileTransferOptions` | `object` | Binary transfer settings (see below) |
|
|
57
|
+
|
|
58
|
+
### File Transfer Options
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
ape(app, {
|
|
62
|
+
where: 'api',
|
|
63
|
+
fileTransferOptions: {
|
|
64
|
+
startTimeout: 60000, // Time to wait for transfer start (ms)
|
|
65
|
+
completeTimeout: 60000 // Time after start before cleanup (ms)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
```
|
|
55
69
|
|
|
56
70
|
### Controller Context (`this`)
|
|
57
71
|
|
|
@@ -91,3 +105,33 @@ api/
|
|
|
91
105
|
│ ├── list.js → ape.users.list(data)
|
|
92
106
|
│ └── create.js → ape.users.create(data)
|
|
93
107
|
```
|
|
108
|
+
|
|
109
|
+
## File Transfers
|
|
110
|
+
|
|
111
|
+
Controllers can return `Buffer` data directly. The framework handles conversion:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
// api/files/download.js
|
|
115
|
+
const fs = require('fs')
|
|
116
|
+
|
|
117
|
+
module.exports = function(filename) {
|
|
118
|
+
return {
|
|
119
|
+
name: filename,
|
|
120
|
+
data: fs.readFileSync(`./uploads/${filename}`)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
For uploads, the controller receives `Buffer` data:
|
|
126
|
+
|
|
127
|
+
```js
|
|
128
|
+
// api/files/upload.js
|
|
129
|
+
module.exports = function({ name, data }) {
|
|
130
|
+
// data is a Buffer
|
|
131
|
+
fs.writeFileSync(`./uploads/${name}`, data)
|
|
132
|
+
return { success: true }
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Binary data is transferred via `/api/ape/data/:hash` with session verification and HTTPS enforcement (localhost exempt).
|
|
137
|
+
|
package/server/index.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* api-ape server entry point
|
|
3
|
-
* Exports the main ape function
|
|
3
|
+
* Exports the main ape function and broadcast utilities
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const ape = require('./lib/main')
|
|
7
|
+
const { broadcast, online, getClients } = require('./lib/broadcast')
|
|
8
|
+
|
|
9
|
+
// Attach broadcast utilities to the main function for clean exports
|
|
10
|
+
ape.broadcast = broadcast
|
|
11
|
+
ape.online = online
|
|
12
|
+
ape.getClients = getClients
|
|
13
|
+
|
|
14
|
+
module.exports = ape
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileTransferManager - Handles temporary binary data endpoints
|
|
3
|
+
*
|
|
4
|
+
* For downloads (server → client):
|
|
5
|
+
* - Registers binary data with a hash
|
|
6
|
+
* - Creates temporary endpoint at GET /api/ape/data/:hash
|
|
7
|
+
* - Verifies session before allowing download
|
|
8
|
+
* - Auto-cleanup after timeout
|
|
9
|
+
*
|
|
10
|
+
* For uploads (client → server):
|
|
11
|
+
* - Registers upload expectation with queryId + pathHash
|
|
12
|
+
* - Receives data via PUT /api/ape/data/:queryId/:pathHash
|
|
13
|
+
* - Waits for matching WS message before processing
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Default timeouts (configurable)
|
|
17
|
+
const DEFAULT_START_TIMEOUT = 60 * 1000 // 1 minute to start download
|
|
18
|
+
const DEFAULT_COMPLETE_TIMEOUT = 60 * 1000 // 1 minute after download starts
|
|
19
|
+
|
|
20
|
+
class FileTransferManager {
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.startTimeout = options.startTimeout || DEFAULT_START_TIMEOUT
|
|
23
|
+
this.completeTimeout = options.completeTimeout || DEFAULT_COMPLETE_TIMEOUT
|
|
24
|
+
|
|
25
|
+
// Map<hash, { data, contentType, sessionHostId, createdAt, downloadStarted, timer }>
|
|
26
|
+
this.pendingDownloads = new Map()
|
|
27
|
+
|
|
28
|
+
// Map<`${queryId}/${pathHash}`, { sessionHostId, createdAt, resolver, rejector, timer, data }>
|
|
29
|
+
this.pendingUploads = new Map()
|
|
30
|
+
|
|
31
|
+
// Cleanup interval
|
|
32
|
+
this._cleanupInterval = setInterval(() => this._cleanup(), 30000)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Register a binary download
|
|
37
|
+
* @param {string} hash - Unique hash for this download
|
|
38
|
+
* @param {Buffer|ArrayBuffer} data - Binary data to serve
|
|
39
|
+
* @param {string} contentType - MIME type (e.g., 'application/octet-stream')
|
|
40
|
+
* @param {string} sessionHostId - Host ID of the client session
|
|
41
|
+
* @returns {string} The hash (for confirmation)
|
|
42
|
+
*/
|
|
43
|
+
registerDownload(hash, data, contentType, sessionHostId) {
|
|
44
|
+
// Clear any existing entry with same hash
|
|
45
|
+
if (this.pendingDownloads.has(hash)) {
|
|
46
|
+
const existing = this.pendingDownloads.get(hash)
|
|
47
|
+
if (existing.timer) clearTimeout(existing.timer)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const entry = {
|
|
51
|
+
data,
|
|
52
|
+
contentType: contentType || 'application/octet-stream',
|
|
53
|
+
sessionHostId,
|
|
54
|
+
createdAt: Date.now(),
|
|
55
|
+
downloadStarted: false,
|
|
56
|
+
timer: setTimeout(() => {
|
|
57
|
+
// Auto-remove if download never started
|
|
58
|
+
if (!this.pendingDownloads.get(hash)?.downloadStarted) {
|
|
59
|
+
this.pendingDownloads.delete(hash)
|
|
60
|
+
console.log(`📦 Download expired (never started): ${hash}`)
|
|
61
|
+
}
|
|
62
|
+
}, this.startTimeout)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.pendingDownloads.set(hash, entry)
|
|
66
|
+
console.log(`📦 Registered download: ${hash} for session ${sessionHostId}`)
|
|
67
|
+
return hash
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get download data (called by HTTP handler)
|
|
72
|
+
* @param {string} hash - Download hash
|
|
73
|
+
* @param {string} requestingHostId - Host ID of requester (from session/cookie)
|
|
74
|
+
* @returns {{ data: Buffer, contentType: string } | null}
|
|
75
|
+
*/
|
|
76
|
+
getDownload(hash, requestingHostId) {
|
|
77
|
+
const entry = this.pendingDownloads.get(hash)
|
|
78
|
+
|
|
79
|
+
if (!entry) {
|
|
80
|
+
console.warn(`📦 Download not found: ${hash}`)
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Session verification
|
|
85
|
+
if (entry.sessionHostId !== requestingHostId) {
|
|
86
|
+
console.warn(`📦 Session mismatch for ${hash}: expected ${entry.sessionHostId}, got ${requestingHostId}`)
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Mark download as started
|
|
91
|
+
if (!entry.downloadStarted) {
|
|
92
|
+
entry.downloadStarted = true
|
|
93
|
+
clearTimeout(entry.timer)
|
|
94
|
+
|
|
95
|
+
// Set new timer for cleanup after completion
|
|
96
|
+
entry.timer = setTimeout(() => {
|
|
97
|
+
this.pendingDownloads.delete(hash)
|
|
98
|
+
console.log(`📦 Download cleaned up: ${hash}`)
|
|
99
|
+
}, this.completeTimeout)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
data: entry.data,
|
|
104
|
+
contentType: entry.contentType
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Register an expected upload
|
|
110
|
+
* @param {string} queryId - Query ID from WS message
|
|
111
|
+
* @param {string} pathHash - Hash of property path
|
|
112
|
+
* @param {string} sessionHostId - Host ID of the client session
|
|
113
|
+
* @returns {Promise<Buffer>} Resolves when upload is received
|
|
114
|
+
*/
|
|
115
|
+
registerUpload(queryId, pathHash, sessionHostId) {
|
|
116
|
+
const key = `${queryId}/${pathHash}`
|
|
117
|
+
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const entry = {
|
|
120
|
+
sessionHostId,
|
|
121
|
+
createdAt: Date.now(),
|
|
122
|
+
resolver: resolve,
|
|
123
|
+
rejector: reject,
|
|
124
|
+
data: null,
|
|
125
|
+
timer: setTimeout(() => {
|
|
126
|
+
this.pendingUploads.delete(key)
|
|
127
|
+
reject(new Error(`Upload timeout: ${key}`))
|
|
128
|
+
}, this.startTimeout)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.pendingUploads.set(key, entry)
|
|
132
|
+
console.log(`📤 Registered upload expectation: ${key} for session ${sessionHostId}`)
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Receive upload data (called by HTTP handler)
|
|
138
|
+
* @param {string} queryId - Query ID from URL
|
|
139
|
+
* @param {string} pathHash - Path hash from URL
|
|
140
|
+
* @param {Buffer} data - Uploaded binary data
|
|
141
|
+
* @param {string} requestingHostId - Host ID of uploader
|
|
142
|
+
* @returns {boolean} True if accepted
|
|
143
|
+
*/
|
|
144
|
+
receiveUpload(queryId, pathHash, data, requestingHostId) {
|
|
145
|
+
const key = `${queryId}/${pathHash}`
|
|
146
|
+
const entry = this.pendingUploads.get(key)
|
|
147
|
+
|
|
148
|
+
if (!entry) {
|
|
149
|
+
console.warn(`📤 Upload not expected: ${key}`)
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Session verification
|
|
154
|
+
if (entry.sessionHostId !== requestingHostId) {
|
|
155
|
+
console.warn(`📤 Session mismatch for upload ${key}: expected ${entry.sessionHostId}, got ${requestingHostId}`)
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Clear timeout and resolve
|
|
160
|
+
clearTimeout(entry.timer)
|
|
161
|
+
entry.resolver(data)
|
|
162
|
+
this.pendingUploads.delete(key)
|
|
163
|
+
console.log(`📤 Upload received: ${key}`)
|
|
164
|
+
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Generate hash for download from queryId and property path
|
|
170
|
+
* @param {string} queryId - The query ID
|
|
171
|
+
* @param {string} propertyPath - The property path (e.g., 'files.0.data')
|
|
172
|
+
* @returns {string} Combined hash
|
|
173
|
+
*/
|
|
174
|
+
static generateHash(queryId, propertyPath) {
|
|
175
|
+
// Simple hash combining queryId and path
|
|
176
|
+
// In production, could use crypto.createHash
|
|
177
|
+
const combined = `${queryId}:${propertyPath}`
|
|
178
|
+
let hash = 0
|
|
179
|
+
for (let i = 0; i < combined.length; i++) {
|
|
180
|
+
const char = combined.charCodeAt(i)
|
|
181
|
+
hash = ((hash << 5) - hash) + char
|
|
182
|
+
hash = hash & hash // Convert to 32bit integer
|
|
183
|
+
}
|
|
184
|
+
return Math.abs(hash).toString(36)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Cleanup expired entries
|
|
189
|
+
* @private
|
|
190
|
+
*/
|
|
191
|
+
_cleanup() {
|
|
192
|
+
const now = Date.now()
|
|
193
|
+
const maxAge = this.startTimeout + this.completeTimeout
|
|
194
|
+
|
|
195
|
+
// Cleanup downloads
|
|
196
|
+
for (const [hash, entry] of this.pendingDownloads) {
|
|
197
|
+
if (now - entry.createdAt > maxAge) {
|
|
198
|
+
clearTimeout(entry.timer)
|
|
199
|
+
this.pendingDownloads.delete(hash)
|
|
200
|
+
console.log(`📦 Cleanup stale download: ${hash}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Cleanup uploads
|
|
205
|
+
for (const [key, entry] of this.pendingUploads) {
|
|
206
|
+
if (now - entry.createdAt > maxAge) {
|
|
207
|
+
clearTimeout(entry.timer)
|
|
208
|
+
entry.rejector(new Error(`Upload expired: ${key}`))
|
|
209
|
+
this.pendingUploads.delete(key)
|
|
210
|
+
console.log(`📤 Cleanup stale upload: ${key}`)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Shutdown cleanup
|
|
217
|
+
*/
|
|
218
|
+
destroy() {
|
|
219
|
+
clearInterval(this._cleanupInterval)
|
|
220
|
+
|
|
221
|
+
// Clear all timers
|
|
222
|
+
for (const entry of this.pendingDownloads.values()) {
|
|
223
|
+
clearTimeout(entry.timer)
|
|
224
|
+
}
|
|
225
|
+
for (const entry of this.pendingUploads.values()) {
|
|
226
|
+
clearTimeout(entry.timer)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.pendingDownloads.clear()
|
|
230
|
+
this.pendingUploads.clear()
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Singleton instance
|
|
235
|
+
let instance = null
|
|
236
|
+
|
|
237
|
+
function getFileTransferManager(options) {
|
|
238
|
+
if (!instance) {
|
|
239
|
+
instance = new FileTransferManager(options)
|
|
240
|
+
}
|
|
241
|
+
return instance
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
FileTransferManager,
|
|
246
|
+
getFileTransferManager
|
|
247
|
+
}
|