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
package/server/adapters/mongo.js
CHANGED
|
@@ -1,161 +1,551 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* MongoDB Adapter for APE Cluster
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* @
|
|
13
|
-
* @
|
|
14
|
-
* @
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {Object} Db
|
|
62
|
+
* MongoDB database instance.
|
|
63
|
+
*
|
|
64
|
+
* @property {function(string): Collection} collection - Get a collection
|
|
65
|
+
*/
|
|
83
66
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
548
|
+
return adapter;
|
|
159
549
|
}
|
|
160
550
|
|
|
161
551
|
module.exports = { createMongoAdapter };
|