api-ape 3.0.2 → 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 +59 -572
  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 -203
  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 +31 -6
  44. package/server/README.md +272 -67
  45. package/server/adapters/README.md +23 -14
  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 +322 -71
  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 -219
  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 -224
  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 -311
  186. package/server/lib/broadcast.js +0 -146
@@ -1,172 +1,555 @@
1
1
  /**
2
- * Firebase Realtime Database Adapter for APE Cluster
3
- *
4
- * Uses Firebase RTDB for real-time inter-server messaging.
5
- * Perfect for serverless and edge deployments.
6
- *
7
- * Firebase provides native real-time push via onValue/onChildAdded listeners.
2
+ * @fileoverview Firebase Realtime Database Adapter for APE Cluster
3
+ *
4
+ * This adapter enables multi-server api-ape deployments using Firebase Realtime Database
5
+ * as the coordination backend. Firebase RTDB provides native real-time push capabilities
6
+ * via `onValue` and `onChildAdded` listeners, making it ideal for serverless and edge
7
+ * deployments where traditional databases may not be available.
8
+ *
9
+ * Features:
10
+ * - **Real-time messaging**: Uses Firebase's native push notifications for instant delivery
11
+ * - **Serverless-friendly**: Works well with Cloud Functions, Vercel Edge, Cloudflare Workers
12
+ * - **Auto-cleanup**: Messages are automatically cleaned up after processing
13
+ * - **State machine**: Prevents invalid state transitions (INIT → JOINED → LEFT)
14
+ *
15
+ * Data Structure in Firebase:
16
+ * ```
17
+ * {namespace}/
18
+ * clients/
19
+ * {clientId}/
20
+ * serverId: "server-xyz"
21
+ * updatedAt: 1234567890
22
+ * channels/
23
+ * {serverId}/
24
+ * {pushId}/
25
+ * targetServerId: "server-xyz"
26
+ * senderServerId: "server-abc"
27
+ * message: {...}
28
+ * timestamp: 1234567890
29
+ * ALL/
30
+ * {pushId}/
31
+ * ...broadcast messages...
32
+ * ```
33
+ *
34
+ * @module server/adapters/firebase
35
+ * @see {@link module:server/adapters} - Main adapter factory
36
+ * @see {@link https://firebase.google.com/docs/database} - Firebase RTDB documentation
37
+ *
38
+ * @example
39
+ * // Using with firebase-admin (Node.js server)
40
+ * const admin = require('firebase-admin')
41
+ * admin.initializeApp({ credential: admin.credential.cert(serviceAccount) })
42
+ * const database = admin.database()
43
+ *
44
+ * const { createFirebaseAdapter } = require('api-ape/server/adapters/firebase')
45
+ * const adapter = await createFirebaseAdapter(database, { serverId: 'server-1' })
46
+ * await adapter.join()
47
+ *
48
+ * @example
49
+ * // Using with firebase client SDK (client-side or hybrid)
50
+ * import { getDatabase } from 'firebase/database'
51
+ * const database = getDatabase(app)
52
+ *
53
+ * const adapter = await createFirebaseAdapter(database, {
54
+ * serverId: 'edge-server-1',
55
+ * namespace: 'production'
56
+ * })
8
57
  */
9
58
 
10
59
  /**
11
- * Create Firebase RTDB adapter
12
- * @param {Database} database - Firebase Realtime Database instance from firebase-admin or firebase
13
- * @param {object} opts
14
- * @param {string} opts.serverId - This server's unique ID
15
- * @param {string} [opts.namespace='ape'] - Path prefix
16
- * @returns {Promise<AdapterInstance>}
60
+ * @typedef {Object} FirebaseDatabase
61
+ * Firebase Realtime Database instance. Can be either:
62
+ * - firebase-admin: `admin.database()` - Server-side SDK
63
+ * - firebase client: `getDatabase(app)` - Client/modular SDK
64
+ *
65
+ * @property {function(string): DatabaseReference} ref - Get a reference to a path
66
+ * @property {function=} goOnline - Reconnect to database (admin SDK)
67
+ * @property {Object=} app - Firebase app instance (client SDK)
17
68
  */
18
- async function createFirebaseAdapter(database, { serverId, namespace = 'ape' }) {
19
- if (!serverId) throw new Error('serverId required');
20
-
21
- // State machine: INIT -> JOINED -> LEFT
22
- let state = 'INIT';
23
- const ownedClients = new Set();
24
- const handlers = new Map();
25
- const unsubscribers = [];
26
-
27
- // Firebase path helpers
28
- const paths = {
29
- clients: () => `${namespace}/clients`,
30
- client: (id) => `${namespace}/clients/${id}`,
31
- channel: (sid) => `${namespace}/channels/${sid || 'ALL'}`,
32
- };
33
-
34
- // Get ref helper (works with both firebase-admin and firebase client SDK)
35
- const ref = (path) => {
36
- // firebase-admin style
37
- if (typeof database.ref === 'function') {
38
- return database.ref(path);
69
+
70
+ /**
71
+ * @typedef {Object} DatabaseReference
72
+ * Firebase database reference for a specific path.
73
+ *
74
+ * @property {function(string, function): function} on - Subscribe to events
75
+ * @property {function(string, function): void} off - Unsubscribe from events
76
+ * @property {function(string): Promise<DataSnapshot>} once - Read data once
77
+ * @property {function(Object): Promise<void>} set - Write data
78
+ * @property {function(Object): DatabaseReference} push - Push new child with auto-ID
79
+ * @property {function(): Promise<void>} remove - Delete this reference
80
+ */
81
+
82
+ /**
83
+ * @typedef {Object} DataSnapshot
84
+ * Firebase data snapshot from a read operation.
85
+ *
86
+ * @property {function(): any} val - Get the data value
87
+ * @property {DatabaseReference} ref - Reference to this snapshot's location
88
+ */
89
+
90
+ /**
91
+ * @typedef {Object} FirebaseAdapterOptions
92
+ * Configuration options for the Firebase adapter.
93
+ *
94
+ * @property {string} serverId - This server's unique identifier (required)
95
+ * @property {string} [namespace='ape'] - Path prefix in Firebase for all data.
96
+ * Use different namespaces to run multiple api-ape clusters on the same database.
97
+ */
98
+
99
+ /**
100
+ * @typedef {Object} FirebaseAdapterInstance
101
+ * A configured Firebase adapter instance for cluster coordination.
102
+ *
103
+ * @property {string} serverId - This server's unique identifier (read-only getter)
104
+ * @property {function(string=): Promise<void>} join - Join the cluster and start listening
105
+ * @property {function(): Promise<void>} leave - Leave the cluster and clean up
106
+ * @property {Object} lookup - Client-to-server mapping operations
107
+ * @property {function(string): Promise<void>} lookup.add - Register a client
108
+ * @property {function(string): Promise<string|null>} lookup.read - Find client's server
109
+ * @property {function(string): Promise<void>} lookup.remove - Remove a client mapping
110
+ * @property {Object} channels - Inter-server messaging
111
+ * @property {function(string, Object): Promise<void>} channels.push - Send message
112
+ * @property {function(string, function): Promise<function>} channels.pull - Subscribe to messages
113
+ */
114
+
115
+ /**
116
+ * @typedef {'INIT'|'JOINED'|'LEFT'} AdapterState
117
+ * State machine states for the adapter lifecycle:
118
+ * - INIT: Initial state, not yet joined
119
+ * - JOINED: Successfully joined the cluster
120
+ * - LEFT: Left the cluster, cannot rejoin (create new adapter instead)
121
+ */
122
+
123
+ /**
124
+ * Creates a Firebase Realtime Database adapter for APE cluster coordination.
125
+ *
126
+ * This function sets up Firebase listeners and provides a unified interface for:
127
+ * - Tracking which clients are connected to which servers
128
+ * - Sending messages between servers in the cluster
129
+ * - Broadcasting messages to all servers
130
+ *
131
+ * The adapter uses a state machine to ensure proper lifecycle management:
132
+ * 1. INIT → JOINED: Call `join()` to start listening for messages
133
+ * 2. JOINED → LEFT: Call `leave()` to clean up and disconnect
134
+ * 3. Cannot transition from LEFT back to JOINED (create new adapter)
135
+ *
136
+ * @async
137
+ * @function createFirebaseAdapter
138
+ * @param {FirebaseDatabase} database - Firebase Realtime Database instance
139
+ * @param {FirebaseAdapterOptions} options - Configuration options
140
+ * @param {string} options.serverId - This server's unique identifier
141
+ * @param {string} [options.namespace='ape'] - Path prefix for Firebase data
142
+ * @returns {Promise<FirebaseAdapterInstance>} Configured adapter instance
143
+ * @throws {Error} If serverId is not provided
144
+ *
145
+ * @example
146
+ * // Basic setup with firebase-admin
147
+ * const admin = require('firebase-admin')
148
+ * admin.initializeApp()
149
+ *
150
+ * const { createFirebaseAdapter } = require('api-ape/server/adapters/firebase')
151
+ * const adapter = await createFirebaseAdapter(admin.database(), {
152
+ * serverId: 'api-server-1'
153
+ * })
154
+ *
155
+ * // Join the cluster
156
+ * await adapter.join()
157
+ *
158
+ * // Register a connected client
159
+ * await adapter.lookup.add('client-abc-123')
160
+ *
161
+ * // Send a message to another server
162
+ * await adapter.channels.push('api-server-2', {
163
+ * type: 'forward',
164
+ * clientId: 'client-xyz',
165
+ * data: { message: 'Hello!' }
166
+ * })
167
+ *
168
+ * // Broadcast to all servers
169
+ * await adapter.channels.push('', { type: 'sync', data: {...} })
170
+ *
171
+ * // Subscribe to messages
172
+ * const unsubscribe = await adapter.channels.pull('', (msg, senderId) => {
173
+ * console.log(`Received from ${senderId}:`, msg)
174
+ * })
175
+ *
176
+ * // Clean up on shutdown
177
+ * process.on('SIGTERM', async () => {
178
+ * await adapter.leave()
179
+ * })
180
+ */
181
+ async function createFirebaseAdapter(
182
+ database,
183
+ { serverId, namespace = "ape" },
184
+ ) {
185
+ if (!serverId) throw new Error("serverId required");
186
+
187
+ /**
188
+ * Current adapter state (INIT → JOINED → LEFT)
189
+ * @type {AdapterState}
190
+ * @private
191
+ */
192
+ let state = "INIT";
193
+
194
+ /**
195
+ * Set of client IDs owned by this server.
196
+ * Used during cleanup to remove only our own client mappings.
197
+ * @type {Set<string>}
198
+ * @private
199
+ */
200
+ const ownedClients = new Set();
201
+
202
+ /**
203
+ * Map of channel handlers keyed by target server ID.
204
+ * Empty string key represents the broadcast channel.
205
+ * @type {Map<string, function(Object, string): void>}
206
+ * @private
207
+ */
208
+ const handlers = new Map();
209
+
210
+ /**
211
+ * Array of unsubscribe functions for Firebase listeners.
212
+ * Called during `leave()` to clean up all subscriptions.
213
+ * @type {Array<function(): void>}
214
+ * @private
215
+ */
216
+ const unsubscribers = [];
217
+
218
+ /**
219
+ * Firebase path helpers.
220
+ * Generates consistent paths for clients and channels.
221
+ * @private
222
+ */
223
+ const paths = {
224
+ /**
225
+ * Get the path to the clients collection.
226
+ * @returns {string} Firebase path
227
+ */
228
+ clients: () => `${namespace}/clients`,
229
+
230
+ /**
231
+ * Get the path to a specific client.
232
+ * @param {string} id - Client ID
233
+ * @returns {string} Firebase path
234
+ */
235
+ client: (id) => `${namespace}/clients/${id}`,
236
+
237
+ /**
238
+ * Get the path to a server's message channel.
239
+ * Empty or null ID returns the broadcast channel "ALL".
240
+ * @param {string|null} sid - Server ID or empty for broadcast
241
+ * @returns {string} Firebase path
242
+ */
243
+ channel: (sid) => `${namespace}/channels/${sid || "ALL"}`,
244
+ };
245
+
246
+ /**
247
+ * Get a database reference helper.
248
+ * Supports both firebase-admin and firebase client SDK styles.
249
+ *
250
+ * @private
251
+ * @function ref
252
+ * @param {string} path - Firebase path
253
+ * @returns {DatabaseReference} Database reference
254
+ * @throws {Error} If database instance is unsupported
255
+ */
256
+ const ref = (path) => {
257
+ // firebase-admin style (has .ref() method directly)
258
+ if (typeof database.ref === "function") {
259
+ return database.ref(path);
260
+ }
261
+ // firebase client SDK style (modular API)
262
+ if (typeof database === "object" && database._checkNotDeleted) {
263
+ const { ref: getRef } = require("firebase/database");
264
+ return getRef(database, path);
265
+ }
266
+ throw new Error("Unsupported Firebase Database instance");
267
+ };
268
+
269
+ /**
270
+ * The adapter instance with all public methods.
271
+ * @type {FirebaseAdapterInstance}
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 Firebase listeners for:
287
+ * - This server's direct message channel
288
+ * - The broadcast channel (ALL)
289
+ *
290
+ * Messages are automatically cleaned up after processing:
291
+ * - Direct messages: Deleted immediately after handling
292
+ * - Broadcast messages: Deleted after 5 seconds (allows other servers to receive)
293
+ *
294
+ * @async
295
+ * @param {string} [id] - Optional server ID override (defaults to constructor serverId)
296
+ * @returns {Promise<void>}
297
+ * @throws {Error} If already joined or previously left
298
+ *
299
+ * @example
300
+ * await adapter.join()
301
+ * console.log('Joined cluster as:', adapter.serverId)
302
+ */
303
+ async join(id) {
304
+ const sid = id || serverId;
305
+ if (!sid?.trim()) throw new Error("serverId required");
306
+ if (state === "JOINED") throw new Error("already joined");
307
+ if (state === "LEFT") throw new Error("cannot rejoin after leave");
308
+
309
+ // Listen to this server's direct message channel
310
+ const serverChannelRef = ref(paths.channel(sid));
311
+ const serverListener = serverChannelRef.on("child_added", (snapshot) => {
312
+ const data = snapshot.val();
313
+ if (data && data.senderServerId !== sid) {
314
+ const handler = handlers.get(sid) || handlers.get("");
315
+ if (handler) {
316
+ handler(data.message, data.senderServerId);
317
+ }
318
+ }
319
+ // Clean up processed message immediately
320
+ snapshot.ref.remove();
321
+ });
322
+ unsubscribers.push(() =>
323
+ serverChannelRef.off("child_added", serverListener),
324
+ );
325
+
326
+ // Listen to broadcast channel (ALL)
327
+ const broadcastRef = ref(paths.channel(""));
328
+ const broadcastListener = broadcastRef.on("child_added", (snapshot) => {
329
+ const data = snapshot.val();
330
+ if (data && data.senderServerId !== sid) {
331
+ const handler = handlers.get("");
332
+ if (handler) {
333
+ handler(data.message, data.senderServerId);
334
+ }
335
+ }
336
+ // Clean up broadcast message after delay (let other servers read it)
337
+ setTimeout(() => snapshot.ref.remove(), 5000);
338
+ });
339
+ unsubscribers.push(() =>
340
+ broadcastRef.off("child_added", broadcastListener),
341
+ );
342
+
343
+ state = "JOINED";
344
+ console.log(`✅ Firebase adapter: joined as ${sid}`);
345
+ },
346
+
347
+ /**
348
+ * Leave the cluster and clean up all resources.
349
+ *
350
+ * This method:
351
+ * 1. Unsubscribes all Firebase listeners
352
+ * 2. Removes all client mappings owned by this server
353
+ * 3. Transitions to LEFT state (cannot rejoin)
354
+ *
355
+ * @async
356
+ * @returns {Promise<void>}
357
+ *
358
+ * @example
359
+ * // Clean shutdown
360
+ * process.on('SIGTERM', async () => {
361
+ * await adapter.leave()
362
+ * process.exit(0)
363
+ * })
364
+ */
365
+ async leave() {
366
+ if (state !== "JOINED") return;
367
+ state = "LEFT";
368
+
369
+ console.log(
370
+ `🔴 Firebase adapter: leaving, cleaning up ${ownedClients.size} clients`,
371
+ );
372
+
373
+ // Unsubscribe all listeners
374
+ for (const unsub of unsubscribers) {
375
+ unsub();
376
+ }
377
+ unsubscribers.length = 0;
378
+
379
+ // Remove all owned client mappings
380
+ for (const clientId of ownedClients) {
381
+ try {
382
+ await ref(paths.client(clientId)).remove();
383
+ } catch (e) {
384
+ console.error(
385
+ `Firebase: failed to remove client ${clientId}`,
386
+ e.message,
387
+ );
39
388
  }
40
- // firebase client SDK style (modular)
41
- if (typeof database === 'object' && database._checkNotDeleted) {
42
- const { ref: getRef } = require('firebase/database');
43
- return getRef(database, path);
389
+ }
390
+ ownedClients.clear();
391
+ },
392
+
393
+ /**
394
+ * Client-to-server mapping operations.
395
+ * Used to track which clients are connected to which servers.
396
+ */
397
+ lookup: {
398
+ /**
399
+ * Register a client as owned by this server.
400
+ * Creates or updates the client mapping in Firebase.
401
+ *
402
+ * @async
403
+ * @param {string} clientId - The client's unique identifier
404
+ * @returns {Promise<void>}
405
+ *
406
+ * @example
407
+ * // When a client connects
408
+ * ws.on('connection', async (socket) => {
409
+ * const clientId = generateClientId()
410
+ * await adapter.lookup.add(clientId)
411
+ * })
412
+ */
413
+ async add(clientId) {
414
+ await ref(paths.client(clientId)).set({
415
+ serverId,
416
+ updatedAt: Date.now(),
417
+ });
418
+ ownedClients.add(clientId);
419
+ console.log(
420
+ `📍 Firebase adapter: registered client ${clientId} -> ${serverId}`,
421
+ );
422
+ },
423
+
424
+ /**
425
+ * Look up which server owns a client.
426
+ *
427
+ * @async
428
+ * @param {string} clientId - The client's unique identifier
429
+ * @returns {Promise<string|null>} Server ID owning the client, or null if not found
430
+ *
431
+ * @example
432
+ * // Route message to correct server
433
+ * const targetServer = await adapter.lookup.read(targetClientId)
434
+ * if (targetServer && targetServer !== adapter.serverId) {
435
+ * await adapter.channels.push(targetServer, message)
436
+ * }
437
+ */
438
+ async read(clientId) {
439
+ const snapshot = await ref(paths.client(clientId)).once("value");
440
+ const data = snapshot.val();
441
+ return data?.serverId || null;
442
+ },
443
+
444
+ /**
445
+ * Remove a client mapping.
446
+ * Can only remove clients owned by this server (security).
447
+ *
448
+ * @async
449
+ * @param {string} clientId - The client's unique identifier
450
+ * @returns {Promise<void>}
451
+ * @throws {Error} If this server doesn't own the client
452
+ *
453
+ * @example
454
+ * // When a client disconnects
455
+ * ws.on('close', async () => {
456
+ * await adapter.lookup.remove(clientId)
457
+ * })
458
+ */
459
+ async remove(clientId) {
460
+ if (!ownedClients.has(clientId)) {
461
+ throw new Error(`not owner: cannot remove client ${clientId}`);
44
462
  }
45
- throw new Error('Unsupported Firebase Database instance');
46
- };
47
-
48
- const adapter = {
49
- get serverId() { return serverId; },
50
-
51
- async join(id) {
52
- const sid = id || serverId;
53
- if (!sid?.trim()) throw new Error('serverId required');
54
- if (state === 'JOINED') throw new Error('already joined');
55
- if (state === 'LEFT') throw new Error('cannot rejoin after leave');
56
-
57
- // Listen to this server's channel
58
- const serverChannelRef = ref(paths.channel(sid));
59
- const serverListener = serverChannelRef.on('child_added', (snapshot) => {
60
- const data = snapshot.val();
61
- if (data && data.senderServerId !== sid) {
62
- const handler = handlers.get(sid) || handlers.get('');
63
- if (handler) {
64
- handler(data.message, data.senderServerId);
65
- }
66
- }
67
- // Clean up processed message
68
- snapshot.ref.remove();
69
- });
70
- unsubscribers.push(() => serverChannelRef.off('child_added', serverListener));
71
-
72
- // Listen to broadcast channel
73
- const broadcastRef = ref(paths.channel(''));
74
- const broadcastListener = broadcastRef.on('child_added', (snapshot) => {
75
- const data = snapshot.val();
76
- if (data && data.senderServerId !== sid) {
77
- const handler = handlers.get('');
78
- if (handler) {
79
- handler(data.message, data.senderServerId);
80
- }
81
- }
82
- // Clean up processed message after short delay (let other servers read it)
83
- setTimeout(() => snapshot.ref.remove(), 5000);
84
- });
85
- unsubscribers.push(() => broadcastRef.off('child_added', broadcastListener));
86
-
87
- state = 'JOINED';
88
- console.log(`✅ Firebase adapter: joined as ${sid}`);
89
- },
90
-
91
- async leave() {
92
- if (state !== 'JOINED') return;
93
- state = 'LEFT';
94
-
95
- console.log(`🔴 Firebase adapter: leaving, cleaning up ${ownedClients.size} clients`);
96
-
97
- // Unsubscribe all listeners
98
- for (const unsub of unsubscribers) {
99
- unsub();
100
- }
101
- unsubscribers.length = 0;
102
-
103
- // Remove all owned client mappings
104
- for (const clientId of ownedClients) {
105
- try {
106
- await ref(paths.client(clientId)).remove();
107
- } catch (e) {
108
- console.error(`Firebase: failed to remove client ${clientId}`, e.message);
109
- }
110
- }
111
- ownedClients.clear();
112
- },
113
-
114
- lookup: {
115
- async add(clientId) {
116
- await ref(paths.client(clientId)).set({
117
- serverId,
118
- updatedAt: Date.now()
119
- });
120
- ownedClients.add(clientId);
121
- console.log(`📍 Firebase adapter: registered client ${clientId} -> ${serverId}`);
122
- },
123
-
124
- async read(clientId) {
125
- const snapshot = await ref(paths.client(clientId)).once('value');
126
- const data = snapshot.val();
127
- return data?.serverId || null;
128
- },
129
-
130
- async remove(clientId) {
131
- if (!ownedClients.has(clientId)) {
132
- throw new Error(`not owner: cannot remove client ${clientId}`);
133
- }
134
- await ref(paths.client(clientId)).remove();
135
- ownedClients.delete(clientId);
136
- console.log(`🗑️ Firebase adapter: removed client ${clientId}`);
137
- }
138
- },
139
-
140
- channels: {
141
- async push(targetServerId, message) {
142
- const channelRef = ref(paths.channel(targetServerId));
143
-
144
- await channelRef.push({
145
- targetServerId: targetServerId || '',
146
- senderServerId: serverId,
147
- message,
148
- timestamp: Date.now()
149
- });
150
-
151
- if (targetServerId) {
152
- console.log(`📤 Firebase adapter: pushed to server ${targetServerId}`);
153
- } else {
154
- console.log(`📢 Firebase adapter: broadcast to all servers`);
155
- }
156
- },
157
-
158
- async pull(targetServerId, handler) {
159
- handlers.set(targetServerId || '', handler);
160
-
161
- // Return unsubscribe function
162
- return async () => {
163
- handlers.delete(targetServerId || '');
164
- };
165
- }
463
+ await ref(paths.client(clientId)).remove();
464
+ ownedClients.delete(clientId);
465
+ console.log(`🗑️ Firebase adapter: removed client ${clientId}`);
466
+ },
467
+ },
468
+
469
+ /**
470
+ * Inter-server messaging operations.
471
+ * Used to send messages between servers in the cluster.
472
+ */
473
+ channels: {
474
+ /**
475
+ * Send a message to a specific server or broadcast to all.
476
+ *
477
+ * @async
478
+ * @param {string} targetServerId - Target server ID, or empty string for broadcast
479
+ * @param {Object} message - Message payload (will be JSON-serialized)
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
+ const channelRef = ref(paths.channel(targetServerId));
499
+
500
+ await channelRef.push({
501
+ targetServerId: targetServerId || "",
502
+ senderServerId: serverId,
503
+ message,
504
+ timestamp: Date.now(),
505
+ });
506
+
507
+ if (targetServerId) {
508
+ console.log(
509
+ `📤 Firebase adapter: pushed to server ${targetServerId}`,
510
+ );
511
+ } else {
512
+ console.log(`📢 Firebase adapter: broadcast to all servers`);
166
513
  }
167
- };
514
+ },
515
+
516
+ /**
517
+ * Subscribe to messages for a specific channel.
518
+ *
519
+ * @async
520
+ * @param {string} targetServerId - Server ID to listen for, or empty for broadcast
521
+ * @param {function(Object, string): void} handler - Callback for received messages
522
+ * - First argument: The message payload
523
+ * - Second argument: The sender's server ID
524
+ * @returns {Promise<function(): Promise<void>>} Unsubscribe function
525
+ *
526
+ * @example
527
+ * // Listen for broadcast messages
528
+ * const unsubscribe = await adapter.channels.pull('', (message, senderId) => {
529
+ * console.log(`Broadcast from ${senderId}:`, message)
530
+ * })
531
+ *
532
+ * // Later, stop listening
533
+ * await unsubscribe()
534
+ *
535
+ * @example
536
+ * // Listen for direct messages
537
+ * await adapter.channels.pull(adapter.serverId, (message, senderId) => {
538
+ * console.log(`Direct message from ${senderId}:`, message)
539
+ * })
540
+ */
541
+ async pull(targetServerId, handler) {
542
+ handlers.set(targetServerId || "", handler);
543
+
544
+ // Return unsubscribe function
545
+ return async () => {
546
+ handlers.delete(targetServerId || "");
547
+ };
548
+ },
549
+ },
550
+ };
168
551
 
169
- return adapter;
552
+ return adapter;
170
553
  }
171
554
 
172
555
  module.exports = { createFirebaseAdapter };