api-ape 3.0.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -570
- package/client/README.md +73 -14
- package/client/auth/crypto/aead.js +214 -0
- package/client/auth/crypto/constants.js +32 -0
- package/client/auth/crypto/encoding.js +104 -0
- package/client/auth/crypto/files.md +27 -0
- package/client/auth/crypto/kdf.js +217 -0
- package/client/auth/crypto-utils.js +118 -0
- package/client/auth/files.md +52 -0
- package/client/auth/key-recovery.js +288 -0
- package/client/auth/recovery/constants.js +37 -0
- package/client/auth/recovery/files.md +23 -0
- package/client/auth/recovery/key-derivation.js +61 -0
- package/client/auth/recovery/sss-browser.js +189 -0
- package/client/auth/share-storage.js +205 -0
- package/client/auth/storage/constants.js +18 -0
- package/client/auth/storage/db.js +132 -0
- package/client/auth/storage/files.md +27 -0
- package/client/auth/storage/keys.js +173 -0
- package/client/auth/storage/shares.js +200 -0
- package/client/browser.js +190 -23
- package/client/connectSocket.js +418 -988
- package/client/connection/README.md +23 -0
- package/client/connection/fileDownload.js +256 -0
- package/client/connection/fileHandling.js +450 -0
- package/client/connection/fileUtils.js +346 -0
- package/client/connection/files.md +71 -0
- package/client/connection/messageHandler.js +105 -0
- package/client/connection/network.js +350 -0
- package/client/connection/proxy.js +233 -0
- package/client/connection/sender.js +333 -0
- package/client/connection/state.js +321 -0
- package/client/connection/subscriptions.js +151 -0
- package/client/files.md +53 -0
- package/client/index.js +298 -142
- package/client/transports/README.md +50 -0
- package/client/transports/files.md +41 -0
- package/client/transports/streamParser.js +195 -0
- package/client/transports/streaming.js +555 -202
- package/dist/ape.js +6 -1
- package/dist/ape.js.map +4 -4
- package/index.d.ts +38 -16
- package/package.json +32 -7
- package/server/README.md +287 -53
- package/server/adapters/README.md +28 -19
- package/server/adapters/files.md +68 -0
- package/server/adapters/firebase.js +543 -160
- package/server/adapters/index.js +362 -112
- package/server/adapters/mongo.js +530 -140
- package/server/adapters/postgres.js +534 -155
- package/server/adapters/redis.js +508 -143
- package/server/adapters/supabase.js +555 -186
- package/server/client/README.md +43 -0
- package/server/client/connection.js +586 -0
- package/server/client/files.md +40 -0
- package/server/client/index.js +342 -0
- package/server/files.md +54 -0
- package/server/index.js +332 -27
- package/server/lib/README.md +26 -0
- package/server/lib/broadcast/clients.js +219 -0
- package/server/lib/broadcast/files.md +58 -0
- package/server/lib/broadcast/index.js +57 -0
- package/server/lib/broadcast/publishProxy.js +110 -0
- package/server/lib/broadcast/pubsub.js +137 -0
- package/server/lib/broadcast/sendProxy.js +103 -0
- package/server/lib/bun.js +315 -99
- package/server/lib/fileTransfer/README.md +63 -0
- package/server/lib/fileTransfer/files.md +30 -0
- package/server/lib/fileTransfer/streaming.js +435 -0
- package/server/lib/fileTransfer.js +710 -326
- package/server/lib/files.md +111 -0
- package/server/lib/httpUtils.js +283 -0
- package/server/lib/loader.js +208 -7
- package/server/lib/longPolling/README.md +63 -0
- package/server/lib/longPolling/files.md +44 -0
- package/server/lib/longPolling/getHandler.js +365 -0
- package/server/lib/longPolling/postHandler.js +327 -0
- package/server/lib/longPolling.js +174 -221
- package/server/lib/main.js +369 -532
- package/server/lib/runtimes/README.md +42 -0
- package/server/lib/runtimes/bun.js +586 -0
- package/server/lib/runtimes/files.md +56 -0
- package/server/lib/runtimes/node.js +511 -0
- package/server/lib/wiring.js +539 -98
- package/server/lib/ws/README.md +35 -0
- package/server/lib/ws/adapters/README.md +54 -0
- package/server/lib/ws/adapters/bun.js +538 -170
- package/server/lib/ws/adapters/deno.js +623 -149
- package/server/lib/ws/adapters/files.md +42 -0
- package/server/lib/ws/files.md +74 -0
- package/server/lib/ws/frames.js +532 -154
- package/server/lib/ws/index.js +207 -10
- package/server/lib/ws/server.js +385 -92
- package/server/lib/ws/socket.js +549 -181
- package/server/lib/wsProvider.js +363 -89
- package/server/plugins/binary.js +282 -0
- package/server/security/README.md +92 -0
- package/server/security/auth/README.md +319 -0
- package/server/security/auth/adapters/files.md +95 -0
- package/server/security/auth/adapters/ldap/constants.js +37 -0
- package/server/security/auth/adapters/ldap/files.md +19 -0
- package/server/security/auth/adapters/ldap/helpers.js +111 -0
- package/server/security/auth/adapters/ldap.js +353 -0
- package/server/security/auth/adapters/oauth2/constants.js +41 -0
- package/server/security/auth/adapters/oauth2/files.md +19 -0
- package/server/security/auth/adapters/oauth2/helpers.js +123 -0
- package/server/security/auth/adapters/oauth2.js +273 -0
- package/server/security/auth/adapters/opaque-handlers.js +314 -0
- package/server/security/auth/adapters/opaque.js +205 -0
- package/server/security/auth/adapters/saml/constants.js +52 -0
- package/server/security/auth/adapters/saml/files.md +19 -0
- package/server/security/auth/adapters/saml/helpers.js +74 -0
- package/server/security/auth/adapters/saml.js +173 -0
- package/server/security/auth/adapters/totp.js +703 -0
- package/server/security/auth/adapters/webauthn.js +625 -0
- package/server/security/auth/files.md +61 -0
- package/server/security/auth/framework/constants.js +27 -0
- package/server/security/auth/framework/files.md +23 -0
- package/server/security/auth/framework/handlers.js +272 -0
- package/server/security/auth/framework/socket-auth.js +177 -0
- package/server/security/auth/handlers/auth-messages.js +143 -0
- package/server/security/auth/handlers/files.md +28 -0
- package/server/security/auth/index.js +290 -0
- package/server/security/auth/mfa/crypto/aead.js +148 -0
- package/server/security/auth/mfa/crypto/constants.js +35 -0
- package/server/security/auth/mfa/crypto/files.md +27 -0
- package/server/security/auth/mfa/crypto/kdf.js +120 -0
- package/server/security/auth/mfa/crypto/utils.js +68 -0
- package/server/security/auth/mfa/crypto-utils.js +80 -0
- package/server/security/auth/mfa/files.md +77 -0
- package/server/security/auth/mfa/ledger/constants.js +75 -0
- package/server/security/auth/mfa/ledger/errors.js +73 -0
- package/server/security/auth/mfa/ledger/files.md +23 -0
- package/server/security/auth/mfa/ledger/share-record.js +32 -0
- package/server/security/auth/mfa/ledger.js +255 -0
- package/server/security/auth/mfa/recovery/constants.js +67 -0
- package/server/security/auth/mfa/recovery/files.md +19 -0
- package/server/security/auth/mfa/recovery/handlers.js +216 -0
- package/server/security/auth/mfa/recovery.js +191 -0
- package/server/security/auth/mfa/sss/constants.js +21 -0
- package/server/security/auth/mfa/sss/files.md +23 -0
- package/server/security/auth/mfa/sss/gf256.js +103 -0
- package/server/security/auth/mfa/sss/serialization.js +82 -0
- package/server/security/auth/mfa/sss.js +161 -0
- package/server/security/auth/mfa/two-of-three/constants.js +58 -0
- package/server/security/auth/mfa/two-of-three/files.md +23 -0
- package/server/security/auth/mfa/two-of-three/handlers.js +241 -0
- package/server/security/auth/mfa/two-of-three/helpers.js +71 -0
- package/server/security/auth/mfa/two-of-three.js +136 -0
- package/server/security/auth/nonce-manager.js +89 -0
- package/server/security/auth/state-machine-mfa.js +269 -0
- package/server/security/auth/state-machine.js +257 -0
- package/server/security/extractRootDomain.js +144 -16
- package/server/security/files.md +51 -0
- package/server/security/origin.js +197 -15
- package/server/security/reply.js +274 -16
- package/server/socket/README.md +119 -0
- package/server/socket/authMiddleware.js +299 -0
- package/server/socket/files.md +86 -0
- package/server/socket/open.js +154 -8
- package/server/socket/pluginHooks.js +334 -0
- package/server/socket/receive.js +184 -225
- package/server/socket/receiveContext.js +117 -0
- package/server/socket/send.js +416 -78
- package/server/socket/tagUtils.js +402 -0
- package/server/utils/README.md +19 -0
- package/server/utils/deepRequire.js +255 -30
- package/server/utils/files.md +57 -0
- package/server/utils/genId.js +182 -20
- package/server/utils/parseUserAgent.js +313 -251
- package/server/utils/userAgent/README.md +65 -0
- package/server/utils/userAgent/files.md +46 -0
- package/server/utils/userAgent/patterns.js +545 -0
- package/utils/README.md +21 -0
- package/utils/files.md +66 -0
- package/utils/jss/README.md +21 -0
- package/utils/jss/decode.js +471 -0
- package/utils/jss/encode.js +312 -0
- package/utils/jss/files.md +68 -0
- package/utils/jss/plugins.js +210 -0
- package/utils/jss.js +219 -273
- package/utils/messageHash.js +238 -35
- package/dist/api-ape.min.js +0 -2
- package/dist/api-ape.min.js.map +0 -7
- package/server/client.js +0 -308
- package/server/lib/broadcast.js +0 -146
|
@@ -1,376 +1,760 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
2
|
+
* @fileoverview Binary File Transfer Manager for api-ape Server
|
|
3
|
+
*
|
|
4
|
+
* This module provides the infrastructure for handling binary data transfers
|
|
5
|
+
* between clients and the server. It manages temporary storage of binary data
|
|
6
|
+
* that is too large or inappropriate to send directly through WebSocket messages.
|
|
7
|
+
*
|
|
8
|
+
* ## Architecture Overview
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* ┌─────────────────────────────────────────────────────────────────────────┐
|
|
12
|
+
* │ FileTransferManager │
|
|
13
|
+
* ├─────────────────────────────────────────────────────────────────────────┤
|
|
14
|
+
* │ │
|
|
15
|
+
* │ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │
|
|
16
|
+
* │ │ pendingDownloads │ │ pendingUploads │ │ _streaming │ │
|
|
17
|
+
* │ │ (Map) │ │ (Map) │ │ (StreamingFile │ │
|
|
18
|
+
* │ │ │ │ │ │ Manager) │ │
|
|
19
|
+
* │ │ Server → Client │ │ Client → Server │ │ Client → Client │ │
|
|
20
|
+
* │ │ binary transfers │ │ binary uploads │ │ file sharing │ │
|
|
21
|
+
* │ └───────────────────┘ └───────────────────┘ └───────────────────┘ │
|
|
22
|
+
* │ │
|
|
23
|
+
* └─────────────────────────────────────────────────────────────────────────┘
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* ## Transfer Types
|
|
27
|
+
*
|
|
28
|
+
* ### 1. Downloads (Server → Client)
|
|
29
|
+
*
|
|
30
|
+
* When a controller returns binary data, it's registered as a pending download:
|
|
31
|
+
* 1. Controller returns `{ image: Buffer.from(...) }`
|
|
32
|
+
* 2. Binary data is extracted and registered with `registerDownload()`
|
|
33
|
+
* 3. Response is sent with hash reference: `{ "image<!L>": "abc123" }`
|
|
34
|
+
* 4. Client fetches binary via `GET /api/ape/data/abc123`
|
|
35
|
+
* 5. Entry is cleaned up after download or timeout
|
|
36
|
+
*
|
|
37
|
+
* ### 2. Uploads (Client → Server)
|
|
38
|
+
*
|
|
39
|
+
* When a client sends binary data in a request:
|
|
40
|
+
* 1. Client sends message with tagged reference: `{ "file<!A>": "xyz789" }`
|
|
41
|
+
* 2. Server calls `registerUpload()` which returns a Promise
|
|
42
|
+
* 3. Client uploads binary via `PUT /api/ape/data/{queryId}/xyz789`
|
|
43
|
+
* 4. `receiveUpload()` resolves the Promise with the data
|
|
44
|
+
* 5. Controller receives the actual binary data
|
|
45
|
+
*
|
|
46
|
+
* ### 3. Streaming (Client → Client)
|
|
47
|
+
*
|
|
48
|
+
* For client-to-client file sharing:
|
|
49
|
+
* 1. Sender registers file with `registerStreamingFile()`
|
|
50
|
+
* 2. Sender uploads data (possibly in chunks)
|
|
51
|
+
* 3. Receiver fetches via `getStreamingFile()`
|
|
52
|
+
* 4. Auto-cleanup after timeout
|
|
53
|
+
*
|
|
54
|
+
* ## Security
|
|
55
|
+
*
|
|
56
|
+
* - Session host ID validation ensures only the intended recipient can access data
|
|
57
|
+
* - Automatic cleanup prevents storage exhaustion
|
|
58
|
+
* - Timeout limits prevent indefinite resource holding
|
|
59
|
+
*
|
|
60
|
+
* ## Timeouts
|
|
61
|
+
*
|
|
62
|
+
* | Timeout | Default | Description |
|
|
63
|
+
* |-----------------|---------|------------------------------------------------|
|
|
64
|
+
* | `startTimeout` | 60s | Time allowed before transfer must begin |
|
|
65
|
+
* | `completeTimeout`| 60s | Time allowed to complete after transfer starts |
|
|
66
|
+
*
|
|
67
|
+
* @module server/lib/fileTransfer
|
|
68
|
+
* @see {@link module:server/socket/send} for download registration
|
|
69
|
+
* @see {@link module:server/socket/receive} for upload handling
|
|
70
|
+
* @see {@link module:server/lib/fileTransfer/streaming} for client-to-client transfers
|
|
71
|
+
*
|
|
72
|
+
* @example <caption>Controller returning binary data</caption>
|
|
73
|
+
* // api/images.js
|
|
74
|
+
* module.exports = async function(data) {
|
|
75
|
+
* const imageBuffer = await loadImage(data.imageId)
|
|
76
|
+
*
|
|
77
|
+
* // Returning a Buffer automatically triggers download registration
|
|
78
|
+
* return {
|
|
79
|
+
* name: 'photo.jpg',
|
|
80
|
+
* image: imageBuffer // Will become { "image<!L>": "hash" }
|
|
81
|
+
* }
|
|
82
|
+
* }
|
|
83
|
+
*
|
|
84
|
+
* @example <caption>Controller receiving binary upload</caption>
|
|
85
|
+
* // api/upload.js
|
|
86
|
+
* module.exports = async function(data) {
|
|
87
|
+
* // data.file is already a Buffer (hydrated from upload)
|
|
88
|
+
* const { file, filename } = data
|
|
89
|
+
*
|
|
90
|
+
* await saveFile(filename, file)
|
|
91
|
+
* return { success: true, size: file.length }
|
|
92
|
+
* }
|
|
14
93
|
*/
|
|
15
94
|
|
|
16
|
-
|
|
17
|
-
const DEFAULT_START_TIMEOUT = 60 * 1000 // 1 minute to start download
|
|
18
|
-
const DEFAULT_COMPLETE_TIMEOUT = 60 * 1000 // 1 minute after download starts
|
|
95
|
+
const { StreamingFileManager } = require("./fileTransfer/streaming");
|
|
19
96
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Default timeout before a transfer must start (milliseconds)
|
|
99
|
+
* @constant {number}
|
|
100
|
+
* @default 60000
|
|
101
|
+
*/
|
|
102
|
+
const DEFAULT_START_TIMEOUT = 60 * 1000;
|
|
24
103
|
|
|
25
|
-
|
|
26
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Default timeout to complete a transfer after it starts (milliseconds)
|
|
106
|
+
* @constant {number}
|
|
107
|
+
* @default 60000
|
|
108
|
+
*/
|
|
109
|
+
const DEFAULT_COMPLETE_TIMEOUT = 60 * 1000;
|
|
27
110
|
|
|
28
|
-
|
|
29
|
-
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {Object} DownloadEntry
|
|
113
|
+
* @description Internal structure for tracking pending downloads
|
|
114
|
+
* @property {Buffer|ArrayBuffer} data - The binary data to be downloaded
|
|
115
|
+
* @property {string} contentType - MIME type of the data
|
|
116
|
+
* @property {string} sessionHostId - Client ID authorized to download
|
|
117
|
+
* @property {number} createdAt - Timestamp when entry was created
|
|
118
|
+
* @property {boolean} downloadStarted - Whether download has begun
|
|
119
|
+
* @property {NodeJS.Timeout} timer - Cleanup timeout handle
|
|
120
|
+
*/
|
|
30
121
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
122
|
+
/**
|
|
123
|
+
* @typedef {Object} UploadEntry
|
|
124
|
+
* @description Internal structure for tracking pending uploads
|
|
125
|
+
* @property {string} sessionHostId - Client ID authorized to upload
|
|
126
|
+
* @property {number} createdAt - Timestamp when entry was created
|
|
127
|
+
* @property {Function} resolver - Promise resolve function
|
|
128
|
+
* @property {Function} rejector - Promise reject function
|
|
129
|
+
* @property {Buffer|null} data - Received data (null until uploaded)
|
|
130
|
+
* @property {NodeJS.Timeout} timer - Timeout handle
|
|
131
|
+
*/
|
|
34
132
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
133
|
+
/**
|
|
134
|
+
* @typedef {Object} FileTransferOptions
|
|
135
|
+
* @description Configuration options for FileTransferManager
|
|
136
|
+
* @property {number} [startTimeout=60000] - Milliseconds before transfer must start
|
|
137
|
+
* @property {number} [completeTimeout=60000] - Milliseconds to complete after starting
|
|
138
|
+
*/
|
|
38
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Manages temporary binary data storage for file transfers
|
|
142
|
+
*
|
|
143
|
+
* This class handles the lifecycle of binary data that passes through the
|
|
144
|
+
* api-ape server. It provides separate handling for:
|
|
145
|
+
* - Downloads: Server sending binary data to clients
|
|
146
|
+
* - Uploads: Clients sending binary data to server
|
|
147
|
+
* - Streaming: Client-to-client file sharing
|
|
148
|
+
*
|
|
149
|
+
* All transfers are temporary with automatic cleanup via timeouts and
|
|
150
|
+
* periodic garbage collection.
|
|
151
|
+
*
|
|
152
|
+
* @class FileTransferManager
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* // Create with custom timeouts
|
|
156
|
+
* const manager = new FileTransferManager({
|
|
157
|
+
* startTimeout: 30000, // 30 seconds to start
|
|
158
|
+
* completeTimeout: 120000 // 2 minutes to complete
|
|
159
|
+
* })
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* // Register a download
|
|
163
|
+
* const hash = manager.registerDownload(
|
|
164
|
+
* 'uniqueHash',
|
|
165
|
+
* imageBuffer,
|
|
166
|
+
* 'image/png',
|
|
167
|
+
* clientId
|
|
168
|
+
* )
|
|
169
|
+
*
|
|
170
|
+
* // Later, client requests the download
|
|
171
|
+
* const result = manager.getDownload('uniqueHash', clientId)
|
|
172
|
+
* if (result) {
|
|
173
|
+
* response.setHeader('Content-Type', result.contentType)
|
|
174
|
+
* response.send(result.data)
|
|
175
|
+
* }
|
|
176
|
+
*/
|
|
177
|
+
class FileTransferManager {
|
|
178
|
+
/**
|
|
179
|
+
* Create a new FileTransferManager instance
|
|
180
|
+
*
|
|
181
|
+
* @param {FileTransferOptions} [options={}] - Configuration options
|
|
182
|
+
* @param {number} [options.startTimeout=60000] - Time allowed before transfer starts
|
|
183
|
+
* @param {number} [options.completeTimeout=60000] - Time allowed to complete transfer
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* // Default timeouts (60 seconds each)
|
|
187
|
+
* const manager = new FileTransferManager()
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* // Custom timeouts
|
|
191
|
+
* const manager = new FileTransferManager({
|
|
192
|
+
* startTimeout: 30000,
|
|
193
|
+
* completeTimeout: 300000
|
|
194
|
+
* })
|
|
195
|
+
*/
|
|
196
|
+
constructor(options = {}) {
|
|
39
197
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* @param {string} fileId - Unique file identifier
|
|
43
|
-
* @param {string} uploaderId - Client ID of uploader
|
|
44
|
-
* @returns {string} The fileId
|
|
198
|
+
* Timeout before transfer must start (milliseconds)
|
|
199
|
+
* @type {number}
|
|
45
200
|
*/
|
|
46
|
-
|
|
47
|
-
// Clear existing entry if any
|
|
48
|
-
if (this.streamingFiles.has(fileId)) {
|
|
49
|
-
const existing = this.streamingFiles.get(fileId)
|
|
50
|
-
if (existing.timer) clearTimeout(existing.timer)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const entry = {
|
|
54
|
-
uploaderId,
|
|
55
|
-
chunks: [],
|
|
56
|
-
totalReceived: 0,
|
|
57
|
-
isComplete: false,
|
|
58
|
-
createdAt: Date.now(),
|
|
59
|
-
timer: setTimeout(() => {
|
|
60
|
-
this.streamingFiles.delete(fileId)
|
|
61
|
-
console.log(`📦 Streaming file expired: ${fileId}`)
|
|
62
|
-
}, this.startTimeout + this.completeTimeout)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
this.streamingFiles.set(fileId, entry)
|
|
66
|
-
console.log(`📦 Registered streaming file: ${fileId} from ${uploaderId}`)
|
|
67
|
-
return fileId
|
|
68
|
-
}
|
|
201
|
+
this.startTimeout = options.startTimeout || DEFAULT_START_TIMEOUT;
|
|
69
202
|
|
|
70
203
|
/**
|
|
71
|
-
*
|
|
72
|
-
* @
|
|
73
|
-
* @param {Buffer} chunk - Data chunk
|
|
74
|
-
* @returns {boolean} True if accepted
|
|
204
|
+
* Timeout to complete transfer after starting (milliseconds)
|
|
205
|
+
* @type {number}
|
|
75
206
|
*/
|
|
76
|
-
|
|
77
|
-
const entry = this.streamingFiles.get(fileId)
|
|
78
|
-
if (!entry) {
|
|
79
|
-
console.warn(`📦 Streaming file not found: ${fileId}`)
|
|
80
|
-
return false
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
entry.chunks.push(chunk)
|
|
84
|
-
entry.totalReceived += chunk.length
|
|
85
|
-
return true
|
|
86
|
-
}
|
|
207
|
+
this.completeTimeout = options.completeTimeout || DEFAULT_COMPLETE_TIMEOUT;
|
|
87
208
|
|
|
88
209
|
/**
|
|
89
|
-
*
|
|
90
|
-
* @
|
|
91
|
-
* @
|
|
92
|
-
* @returns {boolean} True if successful
|
|
210
|
+
* Map of pending downloads: hash → DownloadEntry
|
|
211
|
+
* @type {Map<string, DownloadEntry>}
|
|
212
|
+
* @private
|
|
93
213
|
*/
|
|
94
|
-
|
|
95
|
-
const entry = this.streamingFiles.get(fileId)
|
|
96
|
-
if (!entry) {
|
|
97
|
-
console.warn(`📦 Streaming file not found for completion: ${fileId}`)
|
|
98
|
-
return false
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (data) {
|
|
102
|
-
entry.chunks = [data]
|
|
103
|
-
entry.totalReceived = data.length
|
|
104
|
-
}
|
|
105
|
-
entry.isComplete = true
|
|
106
|
-
|
|
107
|
-
// Reset timer for cleanup after completion
|
|
108
|
-
clearTimeout(entry.timer)
|
|
109
|
-
entry.timer = setTimeout(() => {
|
|
110
|
-
this.streamingFiles.delete(fileId)
|
|
111
|
-
console.log(`📦 Streaming file cleaned up: ${fileId}`)
|
|
112
|
-
}, this.completeTimeout)
|
|
113
|
-
|
|
114
|
-
console.log(`📦 Streaming upload complete: ${fileId} (${entry.totalReceived} bytes)`)
|
|
115
|
-
return true
|
|
116
|
-
}
|
|
214
|
+
this.pendingDownloads = new Map();
|
|
117
215
|
|
|
118
216
|
/**
|
|
119
|
-
*
|
|
120
|
-
* @
|
|
121
|
-
* @
|
|
122
|
-
* @returns {{ data: Buffer, isComplete: boolean, totalReceived: number } | null}
|
|
217
|
+
* Map of pending uploads: "queryId/pathHash" → UploadEntry
|
|
218
|
+
* @type {Map<string, UploadEntry>}
|
|
219
|
+
* @private
|
|
123
220
|
*/
|
|
124
|
-
|
|
125
|
-
const entry = this.streamingFiles.get(fileId)
|
|
126
|
-
if (!entry) {
|
|
127
|
-
return null
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Concatenate chunks
|
|
131
|
-
const data = Buffer.concat(entry.chunks)
|
|
132
|
-
|
|
133
|
-
return {
|
|
134
|
-
data: offset > 0 ? data.slice(offset) : data,
|
|
135
|
-
isComplete: entry.isComplete,
|
|
136
|
-
totalReceived: entry.totalReceived
|
|
137
|
-
}
|
|
138
|
-
}
|
|
221
|
+
this.pendingUploads = new Map();
|
|
139
222
|
|
|
140
223
|
/**
|
|
141
|
-
*
|
|
142
|
-
* @
|
|
143
|
-
* @
|
|
224
|
+
* Streaming file manager for client-to-client transfers
|
|
225
|
+
* @type {StreamingFileManager}
|
|
226
|
+
* @private
|
|
144
227
|
*/
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
228
|
+
this._streaming = new StreamingFileManager(
|
|
229
|
+
this.startTimeout,
|
|
230
|
+
this.completeTimeout,
|
|
231
|
+
);
|
|
148
232
|
|
|
149
233
|
/**
|
|
150
|
-
*
|
|
151
|
-
* @
|
|
152
|
-
* @
|
|
153
|
-
* @param {string} contentType - MIME type (e.g., 'application/octet-stream')
|
|
154
|
-
* @param {string} sessionHostId - Host ID of the client session
|
|
155
|
-
* @returns {string} The hash (for confirmation)
|
|
234
|
+
* Interval timer for periodic cleanup
|
|
235
|
+
* @type {NodeJS.Timeout}
|
|
236
|
+
* @private
|
|
156
237
|
*/
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
238
|
+
this._cleanupInterval = setInterval(() => this._cleanup(), 30000);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
242
|
+
// Streaming File Methods (Client-to-Client)
|
|
243
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Register a new streaming file for client-to-client transfer
|
|
247
|
+
*
|
|
248
|
+
* Creates an entry for a file that will be uploaded by one client
|
|
249
|
+
* and downloaded by another. The file can be uploaded in chunks.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} fileId - Unique identifier for the file
|
|
252
|
+
* @param {string} uploaderId - Client ID of the uploader
|
|
253
|
+
* @returns {string} The fileId (for chaining)
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* // Register file for sharing
|
|
257
|
+
* manager.registerStreamingFile('file123', 'clientABC')
|
|
258
|
+
*
|
|
259
|
+
* // Sender uploads the file
|
|
260
|
+
* // ... upload happens via HTTP PUT ...
|
|
261
|
+
*
|
|
262
|
+
* // Receiver fetches the file
|
|
263
|
+
* const file = manager.getStreamingFile('file123')
|
|
264
|
+
*/
|
|
265
|
+
registerStreamingFile(fileId, uploaderId) {
|
|
266
|
+
return this._streaming.register(fileId, uploaderId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Append a chunk of data to a streaming file
|
|
271
|
+
*
|
|
272
|
+
* Used for chunked uploads where the file is sent in multiple parts.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} fileId - The file identifier
|
|
275
|
+
* @param {Buffer} chunk - Data chunk to append
|
|
276
|
+
* @returns {boolean} True if successful, false if file not found
|
|
277
|
+
*
|
|
278
|
+
* @example
|
|
279
|
+
* // Chunked upload
|
|
280
|
+
* manager.appendChunk('file123', chunk1)
|
|
281
|
+
* manager.appendChunk('file123', chunk2)
|
|
282
|
+
* manager.appendChunk('file123', chunk3)
|
|
283
|
+
* manager.completeStreamingUpload('file123')
|
|
284
|
+
*/
|
|
285
|
+
appendChunk(fileId, chunk) {
|
|
286
|
+
return this._streaming.appendChunk(fileId, chunk);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Mark a streaming file upload as complete
|
|
291
|
+
*
|
|
292
|
+
* Optionally accepts final data to replace any chunked data.
|
|
293
|
+
* After completion, the file is available for download.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} fileId - The file identifier
|
|
296
|
+
* @param {Buffer} [data] - Optional complete file data (replaces chunks)
|
|
297
|
+
* @returns {boolean} True if successful, false if file not found
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* // Complete with final data (replaces any chunks)
|
|
301
|
+
* manager.completeStreamingUpload('file123', completeBuffer)
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* // Complete chunked upload (uses accumulated chunks)
|
|
305
|
+
* manager.completeStreamingUpload('file123')
|
|
306
|
+
*/
|
|
307
|
+
completeStreamingUpload(fileId, data) {
|
|
308
|
+
return this._streaming.complete(fileId, data);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Get a streaming file for download
|
|
313
|
+
*
|
|
314
|
+
* Retrieves the file data, optionally starting from an offset
|
|
315
|
+
* (useful for resuming interrupted downloads).
|
|
316
|
+
*
|
|
317
|
+
* @param {string} fileId - The file identifier
|
|
318
|
+
* @param {number} [offset=0] - Byte offset to start from
|
|
319
|
+
* @returns {{data: Buffer, isComplete: boolean, totalReceived: number}|null}
|
|
320
|
+
* File data and status, or null if not found
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* const file = manager.getStreamingFile('file123')
|
|
324
|
+
* if (file) {
|
|
325
|
+
* res.setHeader('Content-Type', 'application/octet-stream')
|
|
326
|
+
* res.setHeader('X-Complete', file.isComplete ? '1' : '0')
|
|
327
|
+
* res.send(file.data)
|
|
328
|
+
* }
|
|
329
|
+
*
|
|
330
|
+
* @example
|
|
331
|
+
* // Resume from offset
|
|
332
|
+
* const file = manager.getStreamingFile('file123', 1024)
|
|
333
|
+
* // file.data contains bytes starting from offset 1024
|
|
334
|
+
*/
|
|
335
|
+
getStreamingFile(fileId, offset = 0) {
|
|
336
|
+
return this._streaming.get(fileId, offset);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Check if a streaming file exists
|
|
341
|
+
*
|
|
342
|
+
* @param {string} fileId - The file identifier to check
|
|
343
|
+
* @returns {boolean} True if the file exists in streaming storage
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* if (manager.isStreamingFile(hash)) {
|
|
347
|
+
* // Handle as streaming file
|
|
348
|
+
* } else {
|
|
349
|
+
* // Handle as regular download
|
|
350
|
+
* }
|
|
351
|
+
*/
|
|
352
|
+
isStreamingFile(fileId) {
|
|
353
|
+
return this._streaming.has(fileId);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
357
|
+
// Download Handling (Server → Client)
|
|
358
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Register binary data for download by a client
|
|
362
|
+
*
|
|
363
|
+
* Called by the send handler when a controller returns binary data.
|
|
364
|
+
* The data is stored temporarily and can be retrieved via HTTP GET.
|
|
365
|
+
*
|
|
366
|
+
* ## Timeout Behavior
|
|
367
|
+
*
|
|
368
|
+
* 1. Entry created with `startTimeout` timer
|
|
369
|
+
* 2. If download starts before timeout, timer is replaced with `completeTimeout`
|
|
370
|
+
* 3. If download doesn't start in time, entry is deleted
|
|
371
|
+
* 4. After download starts, entry persists for `completeTimeout` duration
|
|
372
|
+
*
|
|
373
|
+
* @param {string} hash - Unique identifier for this download
|
|
374
|
+
* @param {Buffer|ArrayBuffer} data - Binary data to make available
|
|
375
|
+
* @param {string} [contentType='application/octet-stream'] - MIME type
|
|
376
|
+
* @param {string} sessionHostId - Client ID authorized to download
|
|
377
|
+
* @returns {string} The hash (for use in response)
|
|
378
|
+
*
|
|
379
|
+
* @example
|
|
380
|
+
* // Register image data for download
|
|
381
|
+
* const hash = FileTransferManager.generateHash(queryId, 'avatar')
|
|
382
|
+
* manager.registerDownload(hash, imageBuffer, 'image/png', clientId)
|
|
383
|
+
*
|
|
384
|
+
* // Send reference to client: { "avatar<!L>": hash }
|
|
385
|
+
*
|
|
386
|
+
* @example
|
|
387
|
+
* // Re-registering with same hash replaces existing entry
|
|
388
|
+
* manager.registerDownload('hash123', newData, 'text/plain', clientId)
|
|
389
|
+
*/
|
|
390
|
+
registerDownload(hash, data, contentType, sessionHostId) {
|
|
391
|
+
// Clear existing entry if present
|
|
392
|
+
if (this.pendingDownloads.has(hash)) {
|
|
393
|
+
const existing = this.pendingDownloads.get(hash);
|
|
394
|
+
if (existing.timer) clearTimeout(existing.timer);
|
|
182
395
|
}
|
|
183
396
|
|
|
184
397
|
/**
|
|
185
|
-
*
|
|
186
|
-
* @
|
|
187
|
-
* @param {string} requestingHostId - Host ID of requester (from session/cookie)
|
|
188
|
-
* @returns {{ data: Buffer, contentType: string } | null}
|
|
398
|
+
* Download entry structure
|
|
399
|
+
* @type {DownloadEntry}
|
|
189
400
|
*/
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
console.warn(`📦 Session mismatch for ${hash}: expected ${entry.sessionHostId}, got ${requestingHostId}`)
|
|
201
|
-
return null
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Mark download as started
|
|
205
|
-
if (!entry.downloadStarted) {
|
|
206
|
-
entry.downloadStarted = true
|
|
207
|
-
clearTimeout(entry.timer)
|
|
208
|
-
|
|
209
|
-
// Set new timer for cleanup after completion
|
|
210
|
-
entry.timer = setTimeout(() => {
|
|
211
|
-
this.pendingDownloads.delete(hash)
|
|
212
|
-
console.log(`📦 Download cleaned up: ${hash}`)
|
|
213
|
-
}, this.completeTimeout)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
data: entry.data,
|
|
218
|
-
contentType: entry.contentType
|
|
401
|
+
const entry = {
|
|
402
|
+
data,
|
|
403
|
+
contentType: contentType || "application/octet-stream",
|
|
404
|
+
sessionHostId,
|
|
405
|
+
createdAt: Date.now(),
|
|
406
|
+
downloadStarted: false,
|
|
407
|
+
timer: setTimeout(() => {
|
|
408
|
+
// Clean up if download never started
|
|
409
|
+
if (!this.pendingDownloads.get(hash)?.downloadStarted) {
|
|
410
|
+
this.pendingDownloads.delete(hash);
|
|
219
411
|
}
|
|
412
|
+
}, this.startTimeout),
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
this.pendingDownloads.set(hash, entry);
|
|
416
|
+
return hash;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Retrieve binary data for a download request
|
|
421
|
+
*
|
|
422
|
+
* Called by the HTTP handler when a client requests a download.
|
|
423
|
+
* Validates the requesting client matches the authorized session.
|
|
424
|
+
*
|
|
425
|
+
* ## Security
|
|
426
|
+
*
|
|
427
|
+
* - Only the client that received the hash reference can download
|
|
428
|
+
* - Session host ID must match exactly
|
|
429
|
+
* - Returns null for unauthorized requests (no error details leaked)
|
|
430
|
+
*
|
|
431
|
+
* @param {string} hash - The download identifier
|
|
432
|
+
* @param {string} requestingHostId - Client ID making the request
|
|
433
|
+
* @returns {{data: Buffer|ArrayBuffer, contentType: string}|null}
|
|
434
|
+
* Download data and content type, or null if not found/unauthorized
|
|
435
|
+
*
|
|
436
|
+
* @example
|
|
437
|
+
* // In HTTP GET handler
|
|
438
|
+
* const result = manager.getDownload(hash, clientId)
|
|
439
|
+
*
|
|
440
|
+
* if (!result) {
|
|
441
|
+
* res.status(404).json({ error: 'Download not found or unauthorized' })
|
|
442
|
+
* return
|
|
443
|
+
* }
|
|
444
|
+
*
|
|
445
|
+
* res.setHeader('Content-Type', result.contentType)
|
|
446
|
+
* res.setHeader('Content-Length', result.data.length)
|
|
447
|
+
* res.send(result.data)
|
|
448
|
+
*/
|
|
449
|
+
getDownload(hash, requestingHostId) {
|
|
450
|
+
const entry = this.pendingDownloads.get(hash);
|
|
451
|
+
|
|
452
|
+
// Not found
|
|
453
|
+
if (!entry) return null;
|
|
454
|
+
|
|
455
|
+
// Unauthorized - different client
|
|
456
|
+
if (entry.sessionHostId !== requestingHostId) return null;
|
|
457
|
+
|
|
458
|
+
// First access - switch to completion timeout
|
|
459
|
+
if (!entry.downloadStarted) {
|
|
460
|
+
entry.downloadStarted = true;
|
|
461
|
+
clearTimeout(entry.timer);
|
|
462
|
+
entry.timer = setTimeout(() => {
|
|
463
|
+
this.pendingDownloads.delete(hash);
|
|
464
|
+
}, this.completeTimeout);
|
|
220
465
|
}
|
|
221
466
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
467
|
+
return { data: entry.data, contentType: entry.contentType };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
471
|
+
// Upload Handling (Client → Server)
|
|
472
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Register expectation of an incoming upload
|
|
476
|
+
*
|
|
477
|
+
* Called when a message contains tagged binary references. Returns a
|
|
478
|
+
* Promise that resolves when the client uploads the actual data.
|
|
479
|
+
*
|
|
480
|
+
* ## Flow
|
|
481
|
+
*
|
|
482
|
+
* 1. Server receives message with `{ "file<!A>": "hash123" }`
|
|
483
|
+
* 2. `registerUpload()` is called, returns Promise
|
|
484
|
+
* 3. Client uploads binary data to `PUT /api/ape/data/{queryId}/hash123`
|
|
485
|
+
* 4. `receiveUpload()` is called with the data
|
|
486
|
+
* 5. Promise resolves with the binary data
|
|
487
|
+
* 6. Controller receives hydrated data with actual Buffer
|
|
488
|
+
*
|
|
489
|
+
* @param {string} queryId - Message query ID this upload belongs to
|
|
490
|
+
* @param {string} pathHash - Property path hash identifying the upload
|
|
491
|
+
* @param {string} sessionHostId - Client ID expected to upload
|
|
492
|
+
* @returns {Promise<Buffer>} Resolves with uploaded data, rejects on timeout
|
|
493
|
+
*
|
|
494
|
+
* @example
|
|
495
|
+
* // In receive handler
|
|
496
|
+
* const uploadPromise = manager.registerUpload(queryId, 'abc123', clientId)
|
|
497
|
+
*
|
|
498
|
+
* try {
|
|
499
|
+
* const data = await uploadPromise
|
|
500
|
+
* // data is now the uploaded Buffer
|
|
501
|
+
* } catch (err) {
|
|
502
|
+
* // Upload timed out
|
|
503
|
+
* }
|
|
504
|
+
*/
|
|
505
|
+
registerUpload(queryId, pathHash, sessionHostId) {
|
|
506
|
+
const key = `${queryId}/${pathHash}`;
|
|
507
|
+
|
|
508
|
+
return new Promise((resolve, reject) => {
|
|
509
|
+
/**
|
|
510
|
+
* Upload entry structure
|
|
511
|
+
* @type {UploadEntry}
|
|
512
|
+
*/
|
|
513
|
+
const entry = {
|
|
514
|
+
sessionHostId,
|
|
515
|
+
createdAt: Date.now(),
|
|
516
|
+
resolver: resolve,
|
|
517
|
+
rejector: reject,
|
|
518
|
+
data: null,
|
|
519
|
+
timer: setTimeout(() => {
|
|
520
|
+
this.pendingUploads.delete(key);
|
|
521
|
+
reject(new Error(`Upload timeout: ${key}`));
|
|
522
|
+
}, this.startTimeout),
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
this.pendingUploads.set(key, entry);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Receive uploaded binary data
|
|
531
|
+
*
|
|
532
|
+
* Called by the HTTP handler when a client uploads data.
|
|
533
|
+
* Validates the client and resolves the waiting Promise.
|
|
534
|
+
*
|
|
535
|
+
* @param {string} queryId - Message query ID
|
|
536
|
+
* @param {string} pathHash - Property path hash
|
|
537
|
+
* @param {Buffer} data - The uploaded binary data
|
|
538
|
+
* @param {string} requestingHostId - Client ID making the upload
|
|
539
|
+
* @returns {boolean} True if upload was accepted, false otherwise
|
|
540
|
+
*
|
|
541
|
+
* @example
|
|
542
|
+
* // In HTTP PUT handler
|
|
543
|
+
* const success = manager.receiveUpload(queryId, pathHash, bodyBuffer, clientId)
|
|
544
|
+
*
|
|
545
|
+
* if (success) {
|
|
546
|
+
* res.json({ success: true })
|
|
547
|
+
* } else {
|
|
548
|
+
* res.status(404).json({ error: 'Upload not expected or unauthorized' })
|
|
549
|
+
* }
|
|
550
|
+
*/
|
|
551
|
+
receiveUpload(queryId, pathHash, data, requestingHostId) {
|
|
552
|
+
const key = `${queryId}/${pathHash}`;
|
|
553
|
+
const entry = this.pendingUploads.get(key);
|
|
554
|
+
|
|
555
|
+
// Not found - no upload expected
|
|
556
|
+
if (!entry) return false;
|
|
557
|
+
|
|
558
|
+
// Unauthorized - different client
|
|
559
|
+
if (entry.sessionHostId !== requestingHostId) return false;
|
|
560
|
+
|
|
561
|
+
// Clear timeout and resolve Promise
|
|
562
|
+
clearTimeout(entry.timer);
|
|
563
|
+
entry.resolver(data);
|
|
564
|
+
this.pendingUploads.delete(key);
|
|
565
|
+
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
570
|
+
// Static Utilities
|
|
571
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Generate a hash for binary data reference
|
|
575
|
+
*
|
|
576
|
+
* Creates a short, deterministic hash from a query ID and property path.
|
|
577
|
+
* Used to create unique identifiers for binary data in messages.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} queryId - The message query identifier
|
|
580
|
+
* @param {string} propertyPath - Dot-notation path to the property (e.g., 'user.avatar')
|
|
581
|
+
* @returns {string} Base-36 encoded hash string
|
|
582
|
+
* @static
|
|
583
|
+
*
|
|
584
|
+
* @example
|
|
585
|
+
* const hash = FileTransferManager.generateHash('q123abc', 'image')
|
|
586
|
+
* // hash: 'k7m3np' (example)
|
|
587
|
+
*
|
|
588
|
+
* @example
|
|
589
|
+
* // Nested property
|
|
590
|
+
* const hash = FileTransferManager.generateHash('q123abc', 'user.profile.avatar')
|
|
591
|
+
* // hash: 'x9w2qr' (example)
|
|
592
|
+
*/
|
|
593
|
+
static generateHash(queryId, propertyPath) {
|
|
594
|
+
const combined = `${queryId}:${propertyPath}`;
|
|
595
|
+
let hash = 0;
|
|
596
|
+
|
|
597
|
+
for (let i = 0; i < combined.length; i++) {
|
|
598
|
+
const char = combined.charCodeAt(i);
|
|
599
|
+
hash = (hash << 5) - hash + char;
|
|
600
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
248
601
|
}
|
|
249
602
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
entry.resolver(data)
|
|
276
|
-
this.pendingUploads.delete(key)
|
|
277
|
-
console.log(`📤 Upload received: ${key}`)
|
|
278
|
-
|
|
279
|
-
return true
|
|
603
|
+
return Math.abs(hash).toString(36);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
607
|
+
// Internal Methods
|
|
608
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Periodic cleanup of expired entries
|
|
612
|
+
*
|
|
613
|
+
* Called every 30 seconds to remove entries that have exceeded
|
|
614
|
+
* the maximum age (startTimeout + completeTimeout).
|
|
615
|
+
*
|
|
616
|
+
* @private
|
|
617
|
+
*/
|
|
618
|
+
_cleanup() {
|
|
619
|
+
const now = Date.now();
|
|
620
|
+
const maxAge = this.startTimeout + this.completeTimeout;
|
|
621
|
+
|
|
622
|
+
// Clean up expired downloads
|
|
623
|
+
for (const [hash, entry] of this.pendingDownloads) {
|
|
624
|
+
if (now - entry.createdAt > maxAge) {
|
|
625
|
+
clearTimeout(entry.timer);
|
|
626
|
+
this.pendingDownloads.delete(hash);
|
|
627
|
+
}
|
|
280
628
|
}
|
|
281
629
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
// Simple hash combining queryId and path
|
|
290
|
-
// In production, could use crypto.createHash
|
|
291
|
-
const combined = `${queryId}:${propertyPath}`
|
|
292
|
-
let hash = 0
|
|
293
|
-
for (let i = 0; i < combined.length; i++) {
|
|
294
|
-
const char = combined.charCodeAt(i)
|
|
295
|
-
hash = ((hash << 5) - hash) + char
|
|
296
|
-
hash = hash & hash // Convert to 32bit integer
|
|
297
|
-
}
|
|
298
|
-
return Math.abs(hash).toString(36)
|
|
630
|
+
// Clean up expired uploads (reject their promises)
|
|
631
|
+
for (const [key, entry] of this.pendingUploads) {
|
|
632
|
+
if (now - entry.createdAt > maxAge) {
|
|
633
|
+
clearTimeout(entry.timer);
|
|
634
|
+
entry.rejector(new Error(`Upload expired: ${key}`));
|
|
635
|
+
this.pendingUploads.delete(key);
|
|
636
|
+
}
|
|
299
637
|
}
|
|
300
638
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Cleanup streaming files
|
|
329
|
-
for (const [fileId, entry] of this.streamingFiles) {
|
|
330
|
-
if (now - entry.createdAt > maxAge) {
|
|
331
|
-
clearTimeout(entry.timer)
|
|
332
|
-
this.streamingFiles.delete(fileId)
|
|
333
|
-
console.log(`📦 Cleanup stale streaming file: ${fileId}`)
|
|
334
|
-
}
|
|
335
|
-
}
|
|
639
|
+
// Delegate to streaming manager
|
|
640
|
+
this._streaming.cleanup(maxAge);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Destroy the manager and clean up all resources
|
|
645
|
+
*
|
|
646
|
+
* Should be called when shutting down the server to prevent
|
|
647
|
+
* memory leaks and ensure timers are cleared.
|
|
648
|
+
*
|
|
649
|
+
* @example
|
|
650
|
+
* // On server shutdown
|
|
651
|
+
* process.on('SIGTERM', () => {
|
|
652
|
+
* fileTransferManager.destroy()
|
|
653
|
+
* server.close()
|
|
654
|
+
* })
|
|
655
|
+
*/
|
|
656
|
+
destroy() {
|
|
657
|
+
// Stop periodic cleanup
|
|
658
|
+
clearInterval(this._cleanupInterval);
|
|
659
|
+
|
|
660
|
+
// Clear all download timers
|
|
661
|
+
for (const entry of this.pendingDownloads.values()) {
|
|
662
|
+
clearTimeout(entry.timer);
|
|
336
663
|
}
|
|
337
664
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
clearInterval(this._cleanupInterval)
|
|
343
|
-
|
|
344
|
-
// Clear all timers
|
|
345
|
-
for (const entry of this.pendingDownloads.values()) {
|
|
346
|
-
clearTimeout(entry.timer)
|
|
347
|
-
}
|
|
348
|
-
for (const entry of this.pendingUploads.values()) {
|
|
349
|
-
clearTimeout(entry.timer)
|
|
350
|
-
}
|
|
665
|
+
// Clear all upload timers
|
|
666
|
+
for (const entry of this.pendingUploads.values()) {
|
|
667
|
+
clearTimeout(entry.timer);
|
|
668
|
+
}
|
|
351
669
|
|
|
352
|
-
|
|
353
|
-
|
|
670
|
+
// Clear maps
|
|
671
|
+
this.pendingDownloads.clear();
|
|
672
|
+
this.pendingUploads.clear();
|
|
354
673
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
this.streamingFiles.clear()
|
|
360
|
-
}
|
|
674
|
+
// Destroy streaming manager
|
|
675
|
+
this._streaming.destroy();
|
|
676
|
+
}
|
|
361
677
|
}
|
|
362
678
|
|
|
363
|
-
|
|
364
|
-
|
|
679
|
+
/**
|
|
680
|
+
* Singleton instance of FileTransferManager
|
|
681
|
+
*
|
|
682
|
+
* Created on first access via getFileTransferManager().
|
|
683
|
+
*
|
|
684
|
+
* @type {FileTransferManager|null}
|
|
685
|
+
* @private
|
|
686
|
+
*/
|
|
687
|
+
let instance = null;
|
|
365
688
|
|
|
689
|
+
/**
|
|
690
|
+
* Get the singleton FileTransferManager instance
|
|
691
|
+
*
|
|
692
|
+
* Creates the instance on first call with the provided options.
|
|
693
|
+
* Subsequent calls return the same instance (options are ignored).
|
|
694
|
+
*
|
|
695
|
+
* This singleton pattern ensures all parts of api-ape share the
|
|
696
|
+
* same file transfer state.
|
|
697
|
+
*
|
|
698
|
+
* @param {FileTransferOptions} [options] - Configuration options (only used on first call)
|
|
699
|
+
* @returns {FileTransferManager} The singleton instance
|
|
700
|
+
*
|
|
701
|
+
* @example
|
|
702
|
+
* // First call - creates instance with options
|
|
703
|
+
* const manager = getFileTransferManager({
|
|
704
|
+
* startTimeout: 30000,
|
|
705
|
+
* completeTimeout: 120000
|
|
706
|
+
* })
|
|
707
|
+
*
|
|
708
|
+
* // Subsequent calls - returns same instance
|
|
709
|
+
* const sameManager = getFileTransferManager()
|
|
710
|
+
* console.log(manager === sameManager) // true
|
|
711
|
+
*
|
|
712
|
+
* @example
|
|
713
|
+
* // Usage in api-ape initialization
|
|
714
|
+
* const fileTransfer = getFileTransferManager(options.fileTransferOptions)
|
|
715
|
+
*/
|
|
366
716
|
function getFileTransferManager(options) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
717
|
+
if (!instance) {
|
|
718
|
+
instance = new FileTransferManager(options);
|
|
719
|
+
}
|
|
720
|
+
return instance;
|
|
371
721
|
}
|
|
372
722
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
723
|
+
/**
|
|
724
|
+
* Reset the singleton FileTransferManager instance (for test cleanup)
|
|
725
|
+
*
|
|
726
|
+
* Calls destroy() on the existing instance to clear intervals and pending
|
|
727
|
+
* transfers, then sets the singleton to null so a new instance can be created.
|
|
728
|
+
*/
|
|
729
|
+
function resetFileTransferManager() {
|
|
730
|
+
if (instance) {
|
|
731
|
+
instance.destroy();
|
|
732
|
+
instance = null;
|
|
733
|
+
}
|
|
376
734
|
}
|
|
735
|
+
|
|
736
|
+
module.exports = {
|
|
737
|
+
/**
|
|
738
|
+
* FileTransferManager class
|
|
739
|
+
*
|
|
740
|
+
* Use this for creating custom instances or type checking.
|
|
741
|
+
* For normal usage, use getFileTransferManager() instead.
|
|
742
|
+
*
|
|
743
|
+
* @type {typeof FileTransferManager}
|
|
744
|
+
*/
|
|
745
|
+
FileTransferManager,
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Get the singleton FileTransferManager instance
|
|
749
|
+
*
|
|
750
|
+
* @type {function(FileTransferOptions=): FileTransferManager}
|
|
751
|
+
*/
|
|
752
|
+
getFileTransferManager,
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Reset the singleton instance (for test cleanup)
|
|
756
|
+
*
|
|
757
|
+
* @type {function(): void}
|
|
758
|
+
*/
|
|
759
|
+
resetFileTransferManager,
|
|
760
|
+
};
|