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,161 +1,551 @@
1
1
  /**
2
- * MongoDB Adapter for APE Cluster
3
- *
4
- * Uses MongoDB Change Streams for real-time inter-server messaging.
5
- * Requires replica set for change stream support.
2
+ * @fileoverview MongoDB Adapter for APE Cluster
3
+ *
4
+ * This adapter enables multi-server api-ape deployments using MongoDB as the
5
+ * coordination backend. MongoDB Change Streams provide real-time notifications
6
+ * for document changes, enabling instant message delivery between servers.
7
+ *
8
+ * **Important**: Change Streams require a MongoDB replica set. Standalone MongoDB
9
+ * instances do not support change streams. For development, you can use:
10
+ * - MongoDB Atlas (free tier supports change streams)
11
+ * - Local replica set with `mongod --replSet rs0`
12
+ *
13
+ * Features:
14
+ * - **Real-time messaging**: Uses Change Streams for instant delivery
15
+ * - **TTL indexes**: Messages auto-expire after 1 hour
16
+ * - **Atomic operations**: Uses `updateOne` with upsert for client mappings
17
+ * - **State machine**: Prevents invalid state transitions (INIT → JOINED → LEFT)
18
+ *
19
+ * Database Structure:
20
+ * ```
21
+ * {namespace}_cluster/
22
+ * clients/
23
+ * { clientId: "abc", serverId: "server-1", updatedAt: Date }
24
+ * events/
25
+ * { targetServerId: "server-1", senderServerId: "server-2",
26
+ * message: {...}, createdAt: Date }
27
+ * ```
28
+ *
29
+ * @module server/adapters/mongo
30
+ * @see {@link module:server/adapters} - Main adapter factory
31
+ * @see {@link https://docs.mongodb.com/manual/changeStreams/} - MongoDB Change Streams
32
+ *
33
+ * @example
34
+ * // Basic setup with MongoDB client
35
+ * const { MongoClient } = require('mongodb')
36
+ * const client = await MongoClient.connect('mongodb://localhost:27017')
37
+ *
38
+ * const { createMongoAdapter } = require('api-ape/server/adapters/mongo')
39
+ * const adapter = await createMongoAdapter(client, { serverId: 'api-server-1' })
40
+ * await adapter.join()
41
+ *
42
+ * @example
43
+ * // Using with MongoDB Atlas
44
+ * const client = await MongoClient.connect(process.env.MONGODB_URI)
45
+ * const adapter = await createMongoAdapter(client, {
46
+ * serverId: 'production-server-1',
47
+ * namespace: 'myapp'
48
+ * })
6
49
  */
7
50
 
8
51
  /**
9
- * Create MongoDB adapter
10
- * @param {MongoClient} mongoClient - MongoDB client
11
- * @param {object} opts
12
- * @param {string} opts.serverId - This server's unique ID
13
- * @param {string} [opts.namespace='ape'] - Database/collection prefix
14
- * @returns {Promise<AdapterInstance>}
52
+ * @typedef {Object} MongoClient
53
+ * MongoDB client instance from the `mongodb` package.
54
+ *
55
+ * @property {function(string): Db} db - Get a database instance
56
+ * @property {Object} constructor - Constructor with name property
57
+ * @property {string} constructor.name - Should be 'MongoClient'
15
58
  */
16
- async function createMongoAdapter(mongoClient, { serverId, namespace = 'ape' }) {
17
- if (!serverId) throw new Error('serverId required');
18
-
19
- // State machine: INIT -> JOINED -> LEFT
20
- let state = 'INIT';
21
- const ownedClients = new Set();
22
- const handlers = new Map();
23
- let changeStream = null;
24
-
25
- // Use dedicated database for APE cluster
26
- const db = mongoClient.db(`${namespace}_cluster`);
27
- const clientsCol = db.collection('clients');
28
- const eventsCol = db.collection('events');
29
-
30
- // Ensure indexes
31
- async function ensureIndexes() {
32
- await clientsCol.createIndex({ clientId: 1 }, { unique: true });
33
- await clientsCol.createIndex({ serverId: 1 });
34
- // Events TTL - auto-delete after 1 hour
35
- await eventsCol.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 });
36
- await eventsCol.createIndex({ targetServerId: 1, createdAt: 1 });
37
- }
38
-
39
- const adapter = {
40
- get serverId() { return serverId; },
41
-
42
- async join(id) {
43
- const sid = id || serverId;
44
- if (!sid?.trim()) throw new Error('serverId required');
45
- if (state === 'JOINED') throw new Error('already joined');
46
- if (state === 'LEFT') throw new Error('cannot rejoin after leave');
47
-
48
- await ensureIndexes();
49
-
50
- // Watch for events targeted to this server or broadcast
51
- try {
52
- changeStream = eventsCol.watch([
53
- {
54
- $match: {
55
- 'fullDocument.targetServerId': { $in: [sid, ''] },
56
- operationType: 'insert'
57
- }
58
- }
59
- ], { fullDocument: 'updateLookup' });
60
-
61
- changeStream.on('change', (change) => {
62
- if (change.operationType === 'insert') {
63
- const doc = change.fullDocument;
64
- const handler = handlers.get(doc.targetServerId) || handlers.get('');
65
- if (handler) {
66
- handler(doc.message, doc.senderServerId);
67
- }
68
- }
69
- });
70
-
71
- changeStream.on('error', (err) => {
72
- console.error('📛 Mongo adapter: change stream error', err.message);
73
- });
74
-
75
- } catch (e) {
76
- console.warn('⚠️ Mongo adapter: Change streams not available (requires replica set). Falling back to polling.');
77
- // Could implement polling fallback here
78
- }
79
59
 
80
- state = 'JOINED';
81
- console.log(`✅ Mongo adapter: joined as ${sid}`);
82
- },
60
+ /**
61
+ * @typedef {Object} Db
62
+ * MongoDB database instance.
63
+ *
64
+ * @property {function(string): Collection} collection - Get a collection
65
+ */
83
66
 
84
- async leave() {
85
- if (state !== 'JOINED') return;
86
- state = 'LEFT';
67
+ /**
68
+ * @typedef {Object} Collection
69
+ * MongoDB collection instance.
70
+ *
71
+ * @property {function(Object, Object=): Promise<void>} createIndex - Create an index
72
+ * @property {function(Object, Object, Object=): Promise<void>} updateOne - Update one document
73
+ * @property {function(Object): Promise<Object|null>} findOne - Find one document
74
+ * @property {function(Object): Promise<void>} deleteOne - Delete one document
75
+ * @property {function(Object): Promise<void>} deleteMany - Delete multiple documents
76
+ * @property {function(Object): Promise<void>} insertOne - Insert one document
77
+ * @property {function(Array, Object=): ChangeStream} watch - Watch for changes
78
+ */
87
79
 
88
- console.log(`🔴 Mongo adapter: leaving, cleaning up ${ownedClients.size} clients`);
80
+ /**
81
+ * @typedef {Object} ChangeStream
82
+ * MongoDB change stream for real-time notifications.
83
+ *
84
+ * @property {function(string, function): void} on - Subscribe to events
85
+ * @property {function(): Promise<void>} close - Close the change stream
86
+ */
89
87
 
90
- // Close change stream
91
- if (changeStream) {
92
- await changeStream.close();
93
- changeStream = null;
94
- }
88
+ /**
89
+ * @typedef {Object} MongoAdapterOptions
90
+ * Configuration options for the MongoDB adapter.
91
+ *
92
+ * @property {string} serverId - This server's unique identifier (required)
93
+ * @property {string} [namespace='ape'] - Database name prefix.
94
+ * The actual database will be `{namespace}_cluster`.
95
+ */
95
96
 
96
- // Remove all owned client mappings
97
- if (ownedClients.size > 0) {
98
- await clientsCol.deleteMany({
99
- clientId: { $in: Array.from(ownedClients) }
100
- });
101
- }
102
- ownedClients.clear();
103
- },
104
-
105
- lookup: {
106
- async add(clientId) {
107
- await clientsCol.updateOne(
108
- { clientId },
109
- { $set: { clientId, serverId, updatedAt: new Date() } },
110
- { upsert: true }
111
- );
112
- ownedClients.add(clientId);
113
- console.log(`📍 Mongo adapter: registered client ${clientId} -> ${serverId}`);
114
- },
97
+ /**
98
+ * @typedef {Object} MongoAdapterInstance
99
+ * A configured MongoDB adapter instance for cluster coordination.
100
+ *
101
+ * @property {string} serverId - This server's unique identifier (read-only getter)
102
+ * @property {function(string=): Promise<void>} join - Join the cluster and start listening
103
+ * @property {function(): Promise<void>} leave - Leave the cluster and clean up
104
+ * @property {Object} lookup - Client-to-server mapping operations
105
+ * @property {function(string): Promise<void>} lookup.add - Register a client
106
+ * @property {function(string): Promise<string|null>} lookup.read - Find client's server
107
+ * @property {function(string): Promise<void>} lookup.remove - Remove a client mapping
108
+ * @property {Object} channels - Inter-server messaging
109
+ * @property {function(string, Object): Promise<void>} channels.push - Send message
110
+ * @property {function(string, function): Promise<function>} channels.pull - Subscribe to messages
111
+ */
112
+
113
+ /**
114
+ * @typedef {'INIT'|'JOINED'|'LEFT'} AdapterState
115
+ * State machine states for the adapter lifecycle:
116
+ * - INIT: Initial state, not yet joined
117
+ * - JOINED: Successfully joined the cluster
118
+ * - LEFT: Left the cluster, cannot rejoin (create new adapter instead)
119
+ */
120
+
121
+ /**
122
+ * Creates a MongoDB adapter for APE cluster coordination.
123
+ *
124
+ * This function sets up MongoDB collections, indexes, and change streams to provide
125
+ * a unified interface for:
126
+ * - Tracking which clients are connected to which servers
127
+ * - Sending messages between servers in the cluster
128
+ * - Broadcasting messages to all servers
129
+ *
130
+ * The adapter uses a state machine to ensure proper lifecycle management:
131
+ * 1. INIT → JOINED: Call `join()` to start listening for messages
132
+ * 2. JOINED → LEFT: Call `leave()` to clean up and disconnect
133
+ * 3. Cannot transition from LEFT back to JOINED (create new adapter)
134
+ *
135
+ * **Note**: If change streams are not available (standalone MongoDB), the adapter
136
+ * will log a warning and continue without real-time message delivery. Consider
137
+ * implementing a polling fallback for such environments.
138
+ *
139
+ * @async
140
+ * @function createMongoAdapter
141
+ * @param {MongoClient} mongoClient - MongoDB client instance
142
+ * @param {MongoAdapterOptions} options - Configuration options
143
+ * @param {string} options.serverId - This server's unique identifier
144
+ * @param {string} [options.namespace='ape'] - Database/collection prefix
145
+ * @returns {Promise<MongoAdapterInstance>} Configured adapter instance
146
+ * @throws {Error} If serverId is not provided
147
+ *
148
+ * @example
149
+ * // Basic setup
150
+ * const { MongoClient } = require('mongodb')
151
+ * const client = await MongoClient.connect('mongodb://localhost:27017/?replicaSet=rs0')
152
+ *
153
+ * const { createMongoAdapter } = require('api-ape/server/adapters/mongo')
154
+ * const adapter = await createMongoAdapter(client, {
155
+ * serverId: 'api-server-1'
156
+ * })
157
+ *
158
+ * // Join the cluster
159
+ * await adapter.join()
160
+ *
161
+ * // Register a connected client
162
+ * await adapter.lookup.add('client-abc-123')
163
+ *
164
+ * // Send a message to another server
165
+ * await adapter.channels.push('api-server-2', {
166
+ * type: 'forward',
167
+ * clientId: 'client-xyz',
168
+ * data: { message: 'Hello!' }
169
+ * })
170
+ *
171
+ * // Broadcast to all servers
172
+ * await adapter.channels.push('', { type: 'sync', data: {...} })
173
+ *
174
+ * // Subscribe to messages
175
+ * const unsubscribe = await adapter.channels.pull('', (msg, senderId) => {
176
+ * console.log(`Received from ${senderId}:`, msg)
177
+ * })
178
+ *
179
+ * // Clean up on shutdown
180
+ * process.on('SIGTERM', async () => {
181
+ * await adapter.leave()
182
+ * await client.close()
183
+ * })
184
+ */
185
+ async function createMongoAdapter(
186
+ mongoClient,
187
+ { serverId, namespace = "ape" },
188
+ ) {
189
+ if (!serverId) throw new Error("serverId required");
190
+
191
+ /**
192
+ * Current adapter state (INIT → JOINED → LEFT)
193
+ * @type {AdapterState}
194
+ * @private
195
+ */
196
+ let state = "INIT";
197
+
198
+ /**
199
+ * Set of client IDs owned by this server.
200
+ * Used during cleanup to remove only our own client mappings.
201
+ * @type {Set<string>}
202
+ * @private
203
+ */
204
+ const ownedClients = new Set();
205
+
206
+ /**
207
+ * Map of channel handlers keyed by target server ID.
208
+ * Empty string key represents the broadcast channel.
209
+ * @type {Map<string, function(Object, string): void>}
210
+ * @private
211
+ */
212
+ const handlers = new Map();
115
213
 
116
- async read(clientId) {
117
- const doc = await clientsCol.findOne({ clientId });
118
- return doc?.serverId || null;
214
+ /**
215
+ * MongoDB Change Stream for real-time event notifications.
216
+ * @type {ChangeStream|null}
217
+ * @private
218
+ */
219
+ let changeStream = null;
220
+
221
+ /**
222
+ * Dedicated database for APE cluster data.
223
+ * Using a separate database keeps cluster data isolated.
224
+ * @type {Db}
225
+ * @private
226
+ */
227
+ const db = mongoClient.db(`${namespace}_cluster`);
228
+
229
+ /**
230
+ * Collection for client-to-server mappings.
231
+ * Each document: { clientId, serverId, updatedAt }
232
+ * @type {Collection}
233
+ * @private
234
+ */
235
+ const clientsCol = db.collection("clients");
236
+
237
+ /**
238
+ * Collection for inter-server events/messages.
239
+ * Each document: { targetServerId, senderServerId, message, createdAt }
240
+ * Has a TTL index that auto-deletes documents after 1 hour.
241
+ * @type {Collection}
242
+ * @private
243
+ */
244
+ const eventsCol = db.collection("events");
245
+
246
+ /**
247
+ * Ensures required indexes exist on collections.
248
+ * Called during `join()` to set up the database schema.
249
+ *
250
+ * Creates:
251
+ * - Unique index on clients.clientId
252
+ * - Index on clients.serverId (for cleanup queries)
253
+ * - TTL index on events.createdAt (auto-delete after 1 hour)
254
+ * - Compound index on events for efficient queries
255
+ *
256
+ * @async
257
+ * @private
258
+ * @function ensureIndexes
259
+ * @returns {Promise<void>}
260
+ */
261
+ async function ensureIndexes() {
262
+ await clientsCol.createIndex({ clientId: 1 }, { unique: true });
263
+ await clientsCol.createIndex({ serverId: 1 });
264
+ // Events TTL - auto-delete after 1 hour
265
+ await eventsCol.createIndex({ createdAt: 1 }, { expireAfterSeconds: 3600 });
266
+ await eventsCol.createIndex({ targetServerId: 1, createdAt: 1 });
267
+ }
268
+
269
+ /**
270
+ * The adapter instance with all public methods.
271
+ * @type {MongoAdapterInstance}
272
+ */
273
+ const adapter = {
274
+ /**
275
+ * Get this server's unique identifier.
276
+ * @type {string}
277
+ * @readonly
278
+ */
279
+ get serverId() {
280
+ return serverId;
281
+ },
282
+
283
+ /**
284
+ * Join the cluster and start listening for messages.
285
+ *
286
+ * Sets up:
287
+ * - Database indexes for efficient queries
288
+ * - Change stream watching for this server's messages and broadcasts
289
+ *
290
+ * **Note**: Change streams require a MongoDB replica set. If not available,
291
+ * a warning is logged and the adapter continues without real-time delivery.
292
+ *
293
+ * @async
294
+ * @param {string} [id] - Optional server ID override (defaults to constructor serverId)
295
+ * @returns {Promise<void>}
296
+ * @throws {Error} If already joined or previously left
297
+ *
298
+ * @example
299
+ * await adapter.join()
300
+ * console.log('Joined cluster as:', adapter.serverId)
301
+ */
302
+ async join(id) {
303
+ const sid = id || serverId;
304
+ if (!sid?.trim()) throw new Error("serverId required");
305
+ if (state === "JOINED") throw new Error("already joined");
306
+ if (state === "LEFT") throw new Error("cannot rejoin after leave");
307
+
308
+ await ensureIndexes();
309
+
310
+ // Watch for events targeted to this server or broadcast
311
+ try {
312
+ changeStream = eventsCol.watch(
313
+ [
314
+ {
315
+ $match: {
316
+ "fullDocument.targetServerId": { $in: [sid, ""] },
317
+ operationType: "insert",
318
+ },
119
319
  },
320
+ ],
321
+ { fullDocument: "updateLookup" },
322
+ );
120
323
 
121
- async remove(clientId) {
122
- if (!ownedClients.has(clientId)) {
123
- throw new Error(`not owner: cannot remove client ${clientId}`);
124
- }
125
- await clientsCol.deleteOne({ clientId });
126
- ownedClients.delete(clientId);
127
- console.log(`🗑️ Mongo adapter: removed client ${clientId}`);
324
+ changeStream.on("change", (change) => {
325
+ if (change.operationType === "insert") {
326
+ const doc = change.fullDocument;
327
+ const handler =
328
+ handlers.get(doc.targetServerId) || handlers.get("");
329
+ if (handler) {
330
+ handler(doc.message, doc.senderServerId);
128
331
  }
129
- },
130
-
131
- channels: {
132
- async push(targetServerId, message) {
133
- await eventsCol.insertOne({
134
- targetServerId: targetServerId || '',
135
- senderServerId: serverId,
136
- message,
137
- createdAt: new Date()
138
- });
139
-
140
- if (targetServerId) {
141
- console.log(`📤 Mongo adapter: pushed to server ${targetServerId}`);
142
- } else {
143
- console.log(`📢 Mongo adapter: broadcast to all servers`);
144
- }
145
- },
332
+ }
333
+ });
146
334
 
147
- async pull(targetServerId, handler) {
148
- handlers.set(targetServerId || '', handler);
335
+ changeStream.on("error", (err) => {
336
+ console.error("📛 Mongo adapter: change stream error", err.message);
337
+ });
338
+ } catch (e) {
339
+ console.warn(
340
+ "⚠️ Mongo adapter: Change streams not available (requires replica set). Falling back to polling.",
341
+ );
342
+ // Could implement polling fallback here
343
+ }
149
344
 
150
- // Return unsubscribe function
151
- return async () => {
152
- handlers.delete(targetServerId || '');
153
- };
154
- }
345
+ state = "JOINED";
346
+ console.log(`✅ Mongo adapter: joined as ${sid}`);
347
+ },
348
+
349
+ /**
350
+ * Leave the cluster and clean up all resources.
351
+ *
352
+ * This method:
353
+ * 1. Closes the MongoDB change stream
354
+ * 2. Removes all client mappings owned by this server
355
+ * 3. Transitions to LEFT state (cannot rejoin)
356
+ *
357
+ * @async
358
+ * @returns {Promise<void>}
359
+ *
360
+ * @example
361
+ * // Clean shutdown
362
+ * process.on('SIGTERM', async () => {
363
+ * await adapter.leave()
364
+ * await mongoClient.close()
365
+ * process.exit(0)
366
+ * })
367
+ */
368
+ async leave() {
369
+ if (state !== "JOINED") return;
370
+ state = "LEFT";
371
+
372
+ console.log(
373
+ `🔴 Mongo adapter: leaving, cleaning up ${ownedClients.size} clients`,
374
+ );
375
+
376
+ // Close change stream
377
+ if (changeStream) {
378
+ await changeStream.close();
379
+ changeStream = null;
380
+ }
381
+
382
+ // Remove all owned client mappings in a single operation
383
+ if (ownedClients.size > 0) {
384
+ await clientsCol.deleteMany({
385
+ clientId: { $in: Array.from(ownedClients) },
386
+ });
387
+ }
388
+ ownedClients.clear();
389
+ },
390
+
391
+ /**
392
+ * Client-to-server mapping operations.
393
+ * Used to track which clients are connected to which servers.
394
+ */
395
+ lookup: {
396
+ /**
397
+ * Register a client as owned by this server.
398
+ * Uses upsert to handle reconnections gracefully.
399
+ *
400
+ * @async
401
+ * @param {string} clientId - The client's unique identifier
402
+ * @returns {Promise<void>}
403
+ *
404
+ * @example
405
+ * // When a client connects
406
+ * ws.on('connection', async (socket) => {
407
+ * const clientId = generateClientId()
408
+ * await adapter.lookup.add(clientId)
409
+ * })
410
+ */
411
+ async add(clientId) {
412
+ await clientsCol.updateOne(
413
+ { clientId },
414
+ { $set: { clientId, serverId, updatedAt: new Date() } },
415
+ { upsert: true },
416
+ );
417
+ ownedClients.add(clientId);
418
+ console.log(
419
+ `📍 Mongo adapter: registered client ${clientId} -> ${serverId}`,
420
+ );
421
+ },
422
+
423
+ /**
424
+ * Look up which server owns a client.
425
+ *
426
+ * @async
427
+ * @param {string} clientId - The client's unique identifier
428
+ * @returns {Promise<string|null>} Server ID owning the client, or null if not found
429
+ *
430
+ * @example
431
+ * // Route message to correct server
432
+ * const targetServer = await adapter.lookup.read(targetClientId)
433
+ * if (targetServer && targetServer !== adapter.serverId) {
434
+ * await adapter.channels.push(targetServer, message)
435
+ * }
436
+ */
437
+ async read(clientId) {
438
+ const doc = await clientsCol.findOne({ clientId });
439
+ return doc?.serverId || null;
440
+ },
441
+
442
+ /**
443
+ * Remove a client mapping.
444
+ * Can only remove clients owned by this server (security).
445
+ *
446
+ * @async
447
+ * @param {string} clientId - The client's unique identifier
448
+ * @returns {Promise<void>}
449
+ * @throws {Error} If this server doesn't own the client
450
+ *
451
+ * @example
452
+ * // When a client disconnects
453
+ * ws.on('close', async () => {
454
+ * await adapter.lookup.remove(clientId)
455
+ * })
456
+ */
457
+ async remove(clientId) {
458
+ if (!ownedClients.has(clientId)) {
459
+ throw new Error(`not owner: cannot remove client ${clientId}`);
155
460
  }
156
- };
461
+ await clientsCol.deleteOne({ clientId });
462
+ ownedClients.delete(clientId);
463
+ console.log(`🗑️ Mongo adapter: removed client ${clientId}`);
464
+ },
465
+ },
466
+
467
+ /**
468
+ * Inter-server messaging operations.
469
+ * Used to send messages between servers in the cluster.
470
+ */
471
+ channels: {
472
+ /**
473
+ * Send a message to a specific server or broadcast to all.
474
+ * Messages are stored in the events collection and delivered via change streams.
475
+ * They auto-expire after 1 hour thanks to the TTL index.
476
+ *
477
+ * @async
478
+ * @param {string} targetServerId - Target server ID, or empty string for broadcast
479
+ * @param {Object} message - Message payload (will be stored in MongoDB)
480
+ * @returns {Promise<void>}
481
+ *
482
+ * @example
483
+ * // Send to specific server
484
+ * await adapter.channels.push('server-2', {
485
+ * type: 'forward',
486
+ * clientId: 'client-123',
487
+ * data: { text: 'Hello!' }
488
+ * })
489
+ *
490
+ * @example
491
+ * // Broadcast to all servers
492
+ * await adapter.channels.push('', {
493
+ * type: 'sync',
494
+ * data: { config: {...} }
495
+ * })
496
+ */
497
+ async push(targetServerId, message) {
498
+ await eventsCol.insertOne({
499
+ targetServerId: targetServerId || "",
500
+ senderServerId: serverId,
501
+ message,
502
+ createdAt: new Date(),
503
+ });
504
+
505
+ if (targetServerId) {
506
+ console.log(`📤 Mongo adapter: pushed to server ${targetServerId}`);
507
+ } else {
508
+ console.log(`📢 Mongo adapter: broadcast to all servers`);
509
+ }
510
+ },
511
+
512
+ /**
513
+ * Subscribe to messages for a specific channel.
514
+ *
515
+ * @async
516
+ * @param {string} targetServerId - Server ID to listen for, or empty for broadcast
517
+ * @param {function(Object, string): void} handler - Callback for received messages
518
+ * - First argument: The message payload
519
+ * - Second argument: The sender's server ID
520
+ * @returns {Promise<function(): Promise<void>>} Unsubscribe function
521
+ *
522
+ * @example
523
+ * // Listen for broadcast messages
524
+ * const unsubscribe = await adapter.channels.pull('', (message, senderId) => {
525
+ * console.log(`Broadcast from ${senderId}:`, message)
526
+ * })
527
+ *
528
+ * // Later, stop listening
529
+ * await unsubscribe()
530
+ *
531
+ * @example
532
+ * // Listen for direct messages
533
+ * await adapter.channels.pull(adapter.serverId, (message, senderId) => {
534
+ * console.log(`Direct message from ${senderId}:`, message)
535
+ * })
536
+ */
537
+ async pull(targetServerId, handler) {
538
+ handlers.set(targetServerId || "", handler);
539
+
540
+ // Return unsubscribe function
541
+ return async () => {
542
+ handlers.delete(targetServerId || "");
543
+ };
544
+ },
545
+ },
546
+ };
157
547
 
158
- return adapter;
548
+ return adapter;
159
549
  }
160
550
 
161
551
  module.exports = { createMongoAdapter };