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.
Files changed (186) hide show
  1. package/README.md +58 -570
  2. package/client/README.md +73 -14
  3. package/client/auth/crypto/aead.js +214 -0
  4. package/client/auth/crypto/constants.js +32 -0
  5. package/client/auth/crypto/encoding.js +104 -0
  6. package/client/auth/crypto/files.md +27 -0
  7. package/client/auth/crypto/kdf.js +217 -0
  8. package/client/auth/crypto-utils.js +118 -0
  9. package/client/auth/files.md +52 -0
  10. package/client/auth/key-recovery.js +288 -0
  11. package/client/auth/recovery/constants.js +37 -0
  12. package/client/auth/recovery/files.md +23 -0
  13. package/client/auth/recovery/key-derivation.js +61 -0
  14. package/client/auth/recovery/sss-browser.js +189 -0
  15. package/client/auth/share-storage.js +205 -0
  16. package/client/auth/storage/constants.js +18 -0
  17. package/client/auth/storage/db.js +132 -0
  18. package/client/auth/storage/files.md +27 -0
  19. package/client/auth/storage/keys.js +173 -0
  20. package/client/auth/storage/shares.js +200 -0
  21. package/client/browser.js +190 -23
  22. package/client/connectSocket.js +418 -988
  23. package/client/connection/README.md +23 -0
  24. package/client/connection/fileDownload.js +256 -0
  25. package/client/connection/fileHandling.js +450 -0
  26. package/client/connection/fileUtils.js +346 -0
  27. package/client/connection/files.md +71 -0
  28. package/client/connection/messageHandler.js +105 -0
  29. package/client/connection/network.js +350 -0
  30. package/client/connection/proxy.js +233 -0
  31. package/client/connection/sender.js +333 -0
  32. package/client/connection/state.js +321 -0
  33. package/client/connection/subscriptions.js +151 -0
  34. package/client/files.md +53 -0
  35. package/client/index.js +298 -142
  36. package/client/transports/README.md +50 -0
  37. package/client/transports/files.md +41 -0
  38. package/client/transports/streamParser.js +195 -0
  39. package/client/transports/streaming.js +555 -202
  40. package/dist/ape.js +6 -1
  41. package/dist/ape.js.map +4 -4
  42. package/index.d.ts +38 -16
  43. package/package.json +32 -7
  44. package/server/README.md +287 -53
  45. package/server/adapters/README.md +28 -19
  46. package/server/adapters/files.md +68 -0
  47. package/server/adapters/firebase.js +543 -160
  48. package/server/adapters/index.js +362 -112
  49. package/server/adapters/mongo.js +530 -140
  50. package/server/adapters/postgres.js +534 -155
  51. package/server/adapters/redis.js +508 -143
  52. package/server/adapters/supabase.js +555 -186
  53. package/server/client/README.md +43 -0
  54. package/server/client/connection.js +586 -0
  55. package/server/client/files.md +40 -0
  56. package/server/client/index.js +342 -0
  57. package/server/files.md +54 -0
  58. package/server/index.js +332 -27
  59. package/server/lib/README.md +26 -0
  60. package/server/lib/broadcast/clients.js +219 -0
  61. package/server/lib/broadcast/files.md +58 -0
  62. package/server/lib/broadcast/index.js +57 -0
  63. package/server/lib/broadcast/publishProxy.js +110 -0
  64. package/server/lib/broadcast/pubsub.js +137 -0
  65. package/server/lib/broadcast/sendProxy.js +103 -0
  66. package/server/lib/bun.js +315 -99
  67. package/server/lib/fileTransfer/README.md +63 -0
  68. package/server/lib/fileTransfer/files.md +30 -0
  69. package/server/lib/fileTransfer/streaming.js +435 -0
  70. package/server/lib/fileTransfer.js +710 -326
  71. package/server/lib/files.md +111 -0
  72. package/server/lib/httpUtils.js +283 -0
  73. package/server/lib/loader.js +208 -7
  74. package/server/lib/longPolling/README.md +63 -0
  75. package/server/lib/longPolling/files.md +44 -0
  76. package/server/lib/longPolling/getHandler.js +365 -0
  77. package/server/lib/longPolling/postHandler.js +327 -0
  78. package/server/lib/longPolling.js +174 -221
  79. package/server/lib/main.js +369 -532
  80. package/server/lib/runtimes/README.md +42 -0
  81. package/server/lib/runtimes/bun.js +586 -0
  82. package/server/lib/runtimes/files.md +56 -0
  83. package/server/lib/runtimes/node.js +511 -0
  84. package/server/lib/wiring.js +539 -98
  85. package/server/lib/ws/README.md +35 -0
  86. package/server/lib/ws/adapters/README.md +54 -0
  87. package/server/lib/ws/adapters/bun.js +538 -170
  88. package/server/lib/ws/adapters/deno.js +623 -149
  89. package/server/lib/ws/adapters/files.md +42 -0
  90. package/server/lib/ws/files.md +74 -0
  91. package/server/lib/ws/frames.js +532 -154
  92. package/server/lib/ws/index.js +207 -10
  93. package/server/lib/ws/server.js +385 -92
  94. package/server/lib/ws/socket.js +549 -181
  95. package/server/lib/wsProvider.js +363 -89
  96. package/server/plugins/binary.js +282 -0
  97. package/server/security/README.md +92 -0
  98. package/server/security/auth/README.md +319 -0
  99. package/server/security/auth/adapters/files.md +95 -0
  100. package/server/security/auth/adapters/ldap/constants.js +37 -0
  101. package/server/security/auth/adapters/ldap/files.md +19 -0
  102. package/server/security/auth/adapters/ldap/helpers.js +111 -0
  103. package/server/security/auth/adapters/ldap.js +353 -0
  104. package/server/security/auth/adapters/oauth2/constants.js +41 -0
  105. package/server/security/auth/adapters/oauth2/files.md +19 -0
  106. package/server/security/auth/adapters/oauth2/helpers.js +123 -0
  107. package/server/security/auth/adapters/oauth2.js +273 -0
  108. package/server/security/auth/adapters/opaque-handlers.js +314 -0
  109. package/server/security/auth/adapters/opaque.js +205 -0
  110. package/server/security/auth/adapters/saml/constants.js +52 -0
  111. package/server/security/auth/adapters/saml/files.md +19 -0
  112. package/server/security/auth/adapters/saml/helpers.js +74 -0
  113. package/server/security/auth/adapters/saml.js +173 -0
  114. package/server/security/auth/adapters/totp.js +703 -0
  115. package/server/security/auth/adapters/webauthn.js +625 -0
  116. package/server/security/auth/files.md +61 -0
  117. package/server/security/auth/framework/constants.js +27 -0
  118. package/server/security/auth/framework/files.md +23 -0
  119. package/server/security/auth/framework/handlers.js +272 -0
  120. package/server/security/auth/framework/socket-auth.js +177 -0
  121. package/server/security/auth/handlers/auth-messages.js +143 -0
  122. package/server/security/auth/handlers/files.md +28 -0
  123. package/server/security/auth/index.js +290 -0
  124. package/server/security/auth/mfa/crypto/aead.js +148 -0
  125. package/server/security/auth/mfa/crypto/constants.js +35 -0
  126. package/server/security/auth/mfa/crypto/files.md +27 -0
  127. package/server/security/auth/mfa/crypto/kdf.js +120 -0
  128. package/server/security/auth/mfa/crypto/utils.js +68 -0
  129. package/server/security/auth/mfa/crypto-utils.js +80 -0
  130. package/server/security/auth/mfa/files.md +77 -0
  131. package/server/security/auth/mfa/ledger/constants.js +75 -0
  132. package/server/security/auth/mfa/ledger/errors.js +73 -0
  133. package/server/security/auth/mfa/ledger/files.md +23 -0
  134. package/server/security/auth/mfa/ledger/share-record.js +32 -0
  135. package/server/security/auth/mfa/ledger.js +255 -0
  136. package/server/security/auth/mfa/recovery/constants.js +67 -0
  137. package/server/security/auth/mfa/recovery/files.md +19 -0
  138. package/server/security/auth/mfa/recovery/handlers.js +216 -0
  139. package/server/security/auth/mfa/recovery.js +191 -0
  140. package/server/security/auth/mfa/sss/constants.js +21 -0
  141. package/server/security/auth/mfa/sss/files.md +23 -0
  142. package/server/security/auth/mfa/sss/gf256.js +103 -0
  143. package/server/security/auth/mfa/sss/serialization.js +82 -0
  144. package/server/security/auth/mfa/sss.js +161 -0
  145. package/server/security/auth/mfa/two-of-three/constants.js +58 -0
  146. package/server/security/auth/mfa/two-of-three/files.md +23 -0
  147. package/server/security/auth/mfa/two-of-three/handlers.js +241 -0
  148. package/server/security/auth/mfa/two-of-three/helpers.js +71 -0
  149. package/server/security/auth/mfa/two-of-three.js +136 -0
  150. package/server/security/auth/nonce-manager.js +89 -0
  151. package/server/security/auth/state-machine-mfa.js +269 -0
  152. package/server/security/auth/state-machine.js +257 -0
  153. package/server/security/extractRootDomain.js +144 -16
  154. package/server/security/files.md +51 -0
  155. package/server/security/origin.js +197 -15
  156. package/server/security/reply.js +274 -16
  157. package/server/socket/README.md +119 -0
  158. package/server/socket/authMiddleware.js +299 -0
  159. package/server/socket/files.md +86 -0
  160. package/server/socket/open.js +154 -8
  161. package/server/socket/pluginHooks.js +334 -0
  162. package/server/socket/receive.js +184 -225
  163. package/server/socket/receiveContext.js +117 -0
  164. package/server/socket/send.js +416 -78
  165. package/server/socket/tagUtils.js +402 -0
  166. package/server/utils/README.md +19 -0
  167. package/server/utils/deepRequire.js +255 -30
  168. package/server/utils/files.md +57 -0
  169. package/server/utils/genId.js +182 -20
  170. package/server/utils/parseUserAgent.js +313 -251
  171. package/server/utils/userAgent/README.md +65 -0
  172. package/server/utils/userAgent/files.md +46 -0
  173. package/server/utils/userAgent/patterns.js +545 -0
  174. package/utils/README.md +21 -0
  175. package/utils/files.md +66 -0
  176. package/utils/jss/README.md +21 -0
  177. package/utils/jss/decode.js +471 -0
  178. package/utils/jss/encode.js +312 -0
  179. package/utils/jss/files.md +68 -0
  180. package/utils/jss/plugins.js +210 -0
  181. package/utils/jss.js +219 -273
  182. package/utils/messageHash.js +238 -35
  183. package/dist/api-ape.min.js +0 -2
  184. package/dist/api-ape.min.js.map +0 -7
  185. package/server/client.js +0 -308
  186. package/server/lib/broadcast.js +0 -146
@@ -1,376 +1,760 @@
1
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
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
- // 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
95
+ const { StreamingFileManager } = require("./fileTransfer/streaming");
19
96
 
20
- class FileTransferManager {
21
- constructor(options = {}) {
22
- this.startTimeout = options.startTimeout || DEFAULT_START_TIMEOUT
23
- this.completeTimeout = options.completeTimeout || DEFAULT_COMPLETE_TIMEOUT
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
- // Map<hash, { data, contentType, sessionHostId, createdAt, downloadStarted, timer }>
26
- this.pendingDownloads = new Map()
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
- // Map<`${queryId}/${pathHash}`, { sessionHostId, createdAt, resolver, rejector, timer, data }>
29
- this.pendingUploads = new Map()
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
- // Map<fileId, StreamingFileEntry> - for client-to-client streaming
32
- // StreamingFileEntry: { uploaderId, chunks[], totalReceived, isComplete, createdAt, timer }
33
- this.streamingFiles = new Map()
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
- // Cleanup interval
36
- this._cleanupInterval = setInterval(() => this._cleanup(), 30000)
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
- * Register a streaming file (client-to-client transfer)
41
- * Called when <!F> tag is detected in incoming message
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
- registerStreamingFile(fileId, uploaderId) {
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
- * Append a chunk to a streaming file
72
- * @param {string} fileId - File identifier
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
- appendChunk(fileId, chunk) {
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
- * Mark streaming file as complete
90
- * @param {string} fileId - File identifier
91
- * @param {Buffer} data - Complete file data (if not chunked)
92
- * @returns {boolean} True if successful
210
+ * Map of pending downloads: hash → DownloadEntry
211
+ * @type {Map<string, DownloadEntry>}
212
+ * @private
93
213
  */
94
- completeStreamingUpload(fileId, data) {
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
- * Get streaming file data (available bytes so far)
120
- * @param {string} fileId - File identifier
121
- * @param {number} offset - Byte offset to start from (for resumable downloads)
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
- getStreamingFile(fileId, offset = 0) {
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
- * Check if a file ID is a streaming file
142
- * @param {string} fileId - File identifier
143
- * @returns {boolean}
224
+ * Streaming file manager for client-to-client transfers
225
+ * @type {StreamingFileManager}
226
+ * @private
144
227
  */
145
- isStreamingFile(fileId) {
146
- return this.streamingFiles.has(fileId)
147
- }
228
+ this._streaming = new StreamingFileManager(
229
+ this.startTimeout,
230
+ this.completeTimeout,
231
+ );
148
232
 
149
233
  /**
150
- * Register a binary download
151
- * @param {string} hash - Unique hash for this download
152
- * @param {Buffer|ArrayBuffer} data - Binary data to serve
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
- registerDownload(hash, data, contentType, sessionHostId) {
158
- // Clear any existing entry with same hash
159
- if (this.pendingDownloads.has(hash)) {
160
- const existing = this.pendingDownloads.get(hash)
161
- if (existing.timer) clearTimeout(existing.timer)
162
- }
163
-
164
- const entry = {
165
- data,
166
- contentType: contentType || 'application/octet-stream',
167
- sessionHostId,
168
- createdAt: Date.now(),
169
- downloadStarted: false,
170
- timer: setTimeout(() => {
171
- // Auto-remove if download never started
172
- if (!this.pendingDownloads.get(hash)?.downloadStarted) {
173
- this.pendingDownloads.delete(hash)
174
- console.log(`📦 Download expired (never started): ${hash}`)
175
- }
176
- }, this.startTimeout)
177
- }
178
-
179
- this.pendingDownloads.set(hash, entry)
180
- console.log(`📦 Registered download: ${hash} for session ${sessionHostId}`)
181
- return hash
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
- * Get download data (called by HTTP handler)
186
- * @param {string} hash - Download hash
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
- getDownload(hash, requestingHostId) {
191
- const entry = this.pendingDownloads.get(hash)
192
-
193
- if (!entry) {
194
- console.warn(`📦 Download not found: ${hash}`)
195
- return null
196
- }
197
-
198
- // Session verification
199
- if (entry.sessionHostId !== requestingHostId) {
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
- * Register an expected upload
224
- * @param {string} queryId - Query ID from WS message
225
- * @param {string} pathHash - Hash of property path
226
- * @param {string} sessionHostId - Host ID of the client session
227
- * @returns {Promise<Buffer>} Resolves when upload is received
228
- */
229
- registerUpload(queryId, pathHash, sessionHostId) {
230
- const key = `${queryId}/${pathHash}`
231
-
232
- return new Promise((resolve, reject) => {
233
- const entry = {
234
- sessionHostId,
235
- createdAt: Date.now(),
236
- resolver: resolve,
237
- rejector: reject,
238
- data: null,
239
- timer: setTimeout(() => {
240
- this.pendingUploads.delete(key)
241
- reject(new Error(`Upload timeout: ${key}`))
242
- }, this.startTimeout)
243
- }
244
-
245
- this.pendingUploads.set(key, entry)
246
- console.log(`📤 Registered upload expectation: ${key} for session ${sessionHostId}`)
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
- * Receive upload data (called by HTTP handler)
252
- * @param {string} queryId - Query ID from URL
253
- * @param {string} pathHash - Path hash from URL
254
- * @param {Buffer} data - Uploaded binary data
255
- * @param {string} requestingHostId - Host ID of uploader
256
- * @returns {boolean} True if accepted
257
- */
258
- receiveUpload(queryId, pathHash, data, requestingHostId) {
259
- const key = `${queryId}/${pathHash}`
260
- const entry = this.pendingUploads.get(key)
261
-
262
- if (!entry) {
263
- console.warn(`📤 Upload not expected: ${key}`)
264
- return false
265
- }
266
-
267
- // Session verification
268
- if (entry.sessionHostId !== requestingHostId) {
269
- console.warn(`📤 Session mismatch for upload ${key}: expected ${entry.sessionHostId}, got ${requestingHostId}`)
270
- return false
271
- }
272
-
273
- // Clear timeout and resolve
274
- clearTimeout(entry.timer)
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
- * Generate hash for download from queryId and property path
284
- * @param {string} queryId - The query ID
285
- * @param {string} propertyPath - The property path (e.g., 'files.0.data')
286
- * @returns {string} Combined hash
287
- */
288
- static generateHash(queryId, propertyPath) {
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
- * Cleanup expired entries
303
- * @private
304
- */
305
- _cleanup() {
306
- const now = Date.now()
307
- const maxAge = this.startTimeout + this.completeTimeout
308
-
309
- // Cleanup downloads
310
- for (const [hash, entry] of this.pendingDownloads) {
311
- if (now - entry.createdAt > maxAge) {
312
- clearTimeout(entry.timer)
313
- this.pendingDownloads.delete(hash)
314
- console.log(`📦 Cleanup stale download: ${hash}`)
315
- }
316
- }
317
-
318
- // Cleanup uploads
319
- for (const [key, entry] of this.pendingUploads) {
320
- if (now - entry.createdAt > maxAge) {
321
- clearTimeout(entry.timer)
322
- entry.rejector(new Error(`Upload expired: ${key}`))
323
- this.pendingUploads.delete(key)
324
- console.log(`📤 Cleanup stale upload: ${key}`)
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
- * Shutdown cleanup
340
- */
341
- destroy() {
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
- this.pendingDownloads.clear()
353
- this.pendingUploads.clear()
670
+ // Clear maps
671
+ this.pendingDownloads.clear();
672
+ this.pendingUploads.clear();
354
673
 
355
- // Clear streaming file timers
356
- for (const entry of this.streamingFiles.values()) {
357
- clearTimeout(entry.timer)
358
- }
359
- this.streamingFiles.clear()
360
- }
674
+ // Destroy streaming manager
675
+ this._streaming.destroy();
676
+ }
361
677
  }
362
678
 
363
- // Singleton instance
364
- let instance = null
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
- if (!instance) {
368
- instance = new FileTransferManager(options)
369
- }
370
- return instance
717
+ if (!instance) {
718
+ instance = new FileTransferManager(options);
719
+ }
720
+ return instance;
371
721
  }
372
722
 
373
- module.exports = {
374
- FileTransferManager,
375
- getFileTransferManager
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
+ };