api-ape 2.2.2 → 2.3.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/index.d.ts CHANGED
@@ -50,7 +50,7 @@ export interface ControllerContext {
50
50
  os: { name?: string; version?: string }
51
51
  device: { type?: string; vendor?: string; model?: string }
52
52
  }
53
- /** Custom embedded values from onConnent */
53
+ /** Custom embedded values from onConnect */
54
54
  [key: string]: any
55
55
  }
56
56
 
@@ -63,7 +63,7 @@ export type ControllerFunction<T = any, R = any> = (
63
63
  ) => R | Promise<R>
64
64
 
65
65
  /**
66
- * Send function provided to onConnent
66
+ * Send function provided to onConnect
67
67
  */
68
68
  export type SendFunction = {
69
69
  (type: string, data: any): void
@@ -76,7 +76,7 @@ export type SendFunction = {
76
76
  export type AfterHook = (err: Error | null, result: any) => void
77
77
 
78
78
  /**
79
- * Connection lifecycle hooks returned from onConnent
79
+ * Connection lifecycle hooks returned from onConnect
80
80
  */
81
81
  export interface ConnectionHandlers {
82
82
  /** Values to embed into controller context */
@@ -88,11 +88,11 @@ export interface ConnectionHandlers {
88
88
  /** Called on error */
89
89
  onError?: (errorString: string) => void
90
90
  /** Called when client disconnects */
91
- onDisconnent?: () => void
91
+ onDisconnect?: () => void
92
92
  }
93
93
 
94
94
  /**
95
- * onConnent callback signature
95
+ * onConnect callback signature
96
96
  */
97
97
  export type OnConnectCallback = (
98
98
  socket: ApeWebSocket,
@@ -107,7 +107,114 @@ export interface ApeServerOptions {
107
107
  /** Directory containing controller files */
108
108
  where: string
109
109
  /** Connection lifecycle hook */
110
- onConnent?: OnConnectCallback
110
+ onConnect?: OnConnectCallback
111
+ }
112
+
113
+ // =============================================================================
114
+ // 🌲 FOREST - DISTRIBUTED MESH TYPES
115
+ // =============================================================================
116
+
117
+ /**
118
+ * Supported database client types for Forest adapters
119
+ */
120
+ export type ForestDatabaseClient =
121
+ | RedisClient
122
+ | MongoClient
123
+ | PostgresPool
124
+ | SupabaseClient
125
+ | FirebaseDatabase
126
+ | ForestCustomAdapter
127
+
128
+ /** Redis client (node-redis or ioredis) */
129
+ export interface RedisClient {
130
+ duplicate(): RedisClient
131
+ publish(channel: string, message: string): Promise<number>
132
+ subscribe(channel: string): Promise<void>
133
+ on(event: string, handler: (...args: any[]) => void): void
134
+ }
135
+
136
+ /** MongoDB client */
137
+ export interface MongoClient {
138
+ db(name?: string): any
139
+ }
140
+
141
+ /** PostgreSQL pool (pg) */
142
+ export interface PostgresPool {
143
+ query(text: string, values?: any[]): Promise<any>
144
+ connect(): Promise<any>
145
+ }
146
+
147
+ /** Supabase client */
148
+ export interface SupabaseClient {
149
+ from(table: string): any
150
+ channel(name: string): any
151
+ removeChannel(channel: any): Promise<void>
152
+ }
153
+
154
+ /** Firebase Realtime Database */
155
+ export interface FirebaseDatabase {
156
+ ref(path: string): any
157
+ goOnline?(): void
158
+ app?: any
159
+ }
160
+
161
+ /**
162
+ * Forest adapter instance interface
163
+ */
164
+ export interface ForestAdapterInstance {
165
+ /** This server's unique ID */
166
+ readonly serverId: string
167
+
168
+ /** Join the distributed mesh */
169
+ join(serverId?: string): Promise<void>
170
+
171
+ /** Leave the mesh and cleanup */
172
+ leave(): Promise<void>
173
+
174
+ /** Client-to-server lookup operations */
175
+ lookup: {
176
+ /** Register a client on this server */
177
+ add(clientId: string): Promise<void>
178
+ /** Find which server owns a client */
179
+ read(clientId: string): Promise<string | null>
180
+ /** Remove a client mapping (must own it) */
181
+ remove(clientId: string): Promise<void>
182
+ }
183
+
184
+ /** Inter-server channel operations */
185
+ channels: {
186
+ /** Push message to a server's channel (empty string = broadcast) */
187
+ push(serverId: string, message: any): Promise<void>
188
+ /** Subscribe to a server's channel, returns unsubscribe function */
189
+ pull(serverId: string, handler: (message: any, senderServerId: string) => void): Promise<() => Promise<void>>
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Custom adapter interface for implementing your own Forest adapter
195
+ */
196
+ export interface ForestCustomAdapter {
197
+ join(serverId: string): Promise<void>
198
+ leave(): Promise<void>
199
+ lookup: {
200
+ add(clientId: string): Promise<void>
201
+ read(clientId: string): Promise<string | null>
202
+ remove(clientId: string): Promise<void>
203
+ }
204
+ channels: {
205
+ push(serverId: string, message: any): Promise<void>
206
+ pull(serverId: string, handler: (message: any, senderServerId: string) => void): Promise<() => Promise<void>>
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Options for joinVia()
212
+ */
213
+ export interface ForestOptions {
214
+ /** Prefix for keys/tables (default: 'ape') */
215
+ namespace?: string
216
+ /** Custom server ID (default: auto-generated) */
217
+ serverId?: string
111
218
  }
112
219
 
113
220
  /**
@@ -118,18 +225,56 @@ declare function ape(server: HttpServer, options: ApeServerOptions): void
118
225
  declare namespace ape {
119
226
  /** Broadcast to all connected clients */
120
227
  export function broadcast(type: string, data: any, excludeClientId?: string): void
121
- /** Get count of connected clients */
122
- export function online(): number
123
- /** Get all connected client clientIds */
124
- export function getClients(): string[]
228
+
229
+ /**
230
+ * Read-only Map of connected clients
231
+ * Each ClientWrapper provides: clientId, sessionId, embed, agent, sendTo(type, data)
232
+ */
233
+ export const clients: ReadonlyMap<string, ClientWrapper>
234
+
235
+ // =========================================================================
236
+ // 🌲 FOREST - DISTRIBUTED MESH
237
+ // =========================================================================
238
+
239
+ /**
240
+ * Join the distributed mesh for multi-server coordination.
241
+ * Pass any supported database client - APE auto-detects the type.
242
+ *
243
+ * @example
244
+ * // Redis
245
+ * ape.joinVia(redisClient);
246
+ *
247
+ * // With options
248
+ * ape.joinVia(mongoClient, { namespace: 'myapp', serverId: 'srv-1' });
249
+ *
250
+ * // Custom adapter
251
+ * ape.joinVia({ join, leave, lookup, channels });
252
+ */
253
+ export function joinVia(client: ForestDatabaseClient, options?: ForestOptions): Promise<ForestAdapterInstance>
254
+
255
+ /**
256
+ * Leave the distributed mesh gracefully.
257
+ * Removes all client mappings and unsubscribes from channels.
258
+ */
259
+ export function leaveCluster(): Promise<void>
260
+
261
+ /**
262
+ * Current Forest adapter instance (null if not joined)
263
+ */
264
+ export const cluster: ForestAdapterInstance | null
265
+
266
+ /**
267
+ * This server's ID in the cluster (null if not joined)
268
+ */
269
+ export const serverId: string | null
125
270
 
126
271
  // Browser client methods (available when imported in browser context)
127
272
  /** Subscribe to broadcasts from the server */
128
273
  export function on<T = any>(type: string, handler: (message: { err?: Error; type: string; data: T }) => void): void
129
274
  /** Subscribe to connection state changes. Returns unsubscribe function. */
130
275
  export function onConnectionChange(handler: (state: 'offline' | 'walled' | 'disconnected' | 'connecting' | 'connected') => void): () => void
131
- /** Get current transport type */
132
- export function getTransport(): 'websocket' | 'polling' | null
276
+ /** Current transport type (read-only) */
277
+ export const transport: 'websocket' | 'polling' | null
133
278
 
134
279
  /** Call any server function dynamically (browser only) */
135
280
  export function message<T = any, R = any>(data?: T): Promise<R>
@@ -161,8 +306,8 @@ export interface ApeBrowserClient extends ApeSender {
161
306
  /** Subscribe to connection state changes. Returns unsubscribe function. */
162
307
  onConnectionChange(handler: (state: ConnectionState) => void): () => void
163
308
 
164
- /** Get current transport type ('websocket' | 'polling' | null) */
165
- getTransport(): TransportType | null
309
+ /** Current transport type (read-only) */
310
+ readonly transport: TransportType | null
166
311
  }
167
312
 
168
313
  // =============================================================================
@@ -221,11 +366,11 @@ export type SetOnReceiver = {
221
366
  */
222
367
  export interface ApeClient {
223
368
  sender: ApeSender
224
- setOnReciver: SetOnReceiver
369
+ setOnReceiver: SetOnReceiver
225
370
  /** Subscribe to connection state changes. Returns unsubscribe function. */
226
371
  onConnectionChange: (handler: (state: ConnectionState) => void) => () => void
227
- /** Get current transport type ('websocket' | 'polling' | null) */
228
- getTransport: () => TransportType | null
372
+ /** Current transport type (read-only) */
373
+ readonly transport: TransportType | null
229
374
  }
230
375
 
231
376
  /**
@@ -256,5 +401,24 @@ export { connectSocket }
256
401
  // =============================================================================
257
402
 
258
403
  export declare const broadcast: (type: string, data: any) => void
259
- export declare const online: () => number
260
- export declare const getClients: () => string[]
404
+ export declare const clients: ReadonlyMap<string, ClientWrapper>
405
+
406
+ /**
407
+ * Client wrapper providing client info and sendTo function
408
+ */
409
+ export interface ClientWrapper {
410
+ /** Unique client identifier */
411
+ readonly clientId: string
412
+ /** Session ID from cookie (set by outer framework) */
413
+ readonly sessionId: string | null
414
+ /** Embedded values from onConnect */
415
+ readonly embed: Record<string, any>
416
+ /** Parsed user-agent info */
417
+ readonly agent: {
418
+ browser: { name?: string; version?: string }
419
+ os: { name?: string; version?: string }
420
+ device: { type?: string; vendor?: string; model?: string }
421
+ }
422
+ /** Send a message to this specific client */
423
+ sendTo(type: string, data: any): void
424
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-ape",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "Remote Procedure Events (RPE) - A lightweight WebSocket framework for building real-time APIs. Call server functions from the browser like local methods with automatic reconnection, HTTP streaming fallback, and extended JSON encoding.",
5
5
  "main": "index.js",
6
6
  "browser": "./client/index.js",
@@ -33,7 +33,7 @@
33
33
  "demo:nextjs": "cd example/NextJs && docker-compose up --build",
34
34
  "demo:vite": "cd example/Vite && npm install && npm run dev",
35
35
  "demo:bun": "cd example/Bun && npm install && npm start",
36
- "publish": "bash scripts/publish.sh"
36
+ "release": "bash scripts/publish.sh"
37
37
  },
38
38
  "repository": {
39
39
  "type": "git",
package/server/README.md CHANGED
@@ -23,6 +23,14 @@ server/
23
23
  │ └── adapters/ # Runtime-specific adapters
24
24
  │ ├── bun.js # Bun native WebSocket
25
25
  │ └── deno.js # Deno native WebSocket
26
+ ├── adapters/ # 🌲 Forest - Distributed mesh adapters
27
+ │ ├── index.js # Auto-detection & factory
28
+ │ ├── redis.js # Redis PUB/SUB adapter
29
+ │ ├── mongo.js # MongoDB Change Streams adapter
30
+ │ ├── postgres.js # PostgreSQL LISTEN/NOTIFY adapter
31
+ │ ├── supabase.js # Supabase Realtime adapter
32
+ │ ├── firebase.js # Firebase RTDB adapter
33
+ │ └── README.md # Adapter documentation
26
34
  ├── socket/
27
35
  │ ├── receive.js # Incoming message handler
28
36
  │ └── send.js # Outgoing message handler
@@ -47,9 +55,9 @@ const server = createServer()
47
55
 
48
56
  ape(server, {
49
57
  where: 'api', // Controller directory
50
- onConnent: (socket, req, send) => ({
58
+ onConnect: (socket, req, send) => ({
51
59
  embed: { userId: req.session?.userId },
52
- onDisconnent: () => console.log('Client left')
60
+ onDisconnect: () => console.log('Client left')
53
61
  })
54
62
  })
55
63
 
@@ -63,7 +71,7 @@ server.listen(3000)
63
71
  | Option | Type | Description |
64
72
  |--------|------|-------------|
65
73
  | `where` | `string` | Directory containing controller files |
66
- | `onConnent` | `function` | Connection lifecycle hook |
74
+ | `onConnect` | `function` | Connection lifecycle hook |
67
75
  | `fileTransferOptions` | `object` | Binary transfer settings (see below) |
68
76
 
69
77
  ### File Transfer Options
@@ -95,13 +103,13 @@ ape(app, {
95
103
  ### Connection Lifecycle Hooks
96
104
 
97
105
  ```js
98
- onConnent(socket, req, send) {
106
+ onConnect(socket, req, send) {
99
107
  return {
100
108
  embed: { ... }, // Values available as this.* in controllers
101
109
  onReceive: (queryId, data, type) => afterFn,
102
110
  onSend: (data, type) => afterFn,
103
111
  onError: (errStr) => { ... },
104
- onDisconnent: () => { ... }
112
+ onDisconnect: () => { ... }
105
113
  }
106
114
  }
107
115
  ```
@@ -159,6 +167,40 @@ module.exports = function({ name, data }) {
159
167
 
160
168
  Binary data is transferred via `/api/ape/data/:hash` with session verification and HTTPS enforcement (localhost exempt).
161
169
 
170
+ ### Client-to-Client File Streaming (`<!F>`)
171
+
172
+ For sharing files between clients (broadcasts), use the `<!F>` marker. Messages route immediately; file data transfers asynchronously with true streaming support.
173
+
174
+ ```
175
+ Client A → Server: { msg: "here's a file", file<!F>: "hash123" } + HTTP upload
176
+ Server → Client B: { msg: "here's a file", file<!F>: "hash123" } (immediate)
177
+ Client B → Server: GET /api/ape/data/hash123 (streams available bytes)
178
+ ```
179
+
180
+ **Key differences from regular file transfer (`<!A>`/`<!B>`):**
181
+
182
+ | Feature | Regular (`<!A>`/`<!B>`) | Shared (`<!F>`) |
183
+ |---------|------------------------|-----------------|
184
+ | **Session check** | Required | Skipped |
185
+ | **Blocking** | Waits for upload | Non-blocking |
186
+ | **Partial download** | No | Yes (stream what's uploaded) |
187
+ | **Use case** | Client → Server | Client → Client via broadcast |
188
+
189
+ **Server-side flow:**
190
+
191
+ 1. Message with `<!F>` received → streaming file registered
192
+ 2. Controller invoked immediately (non-blocking)
193
+ 3. When broadcast, `<!F>` tags pass through unchanged
194
+ 4. HTTP upload completes streaming file
195
+ 5. Other clients fetch from `/api/ape/data/:hash` (no session check)
196
+
197
+ **Response headers:**
198
+
199
+ | Header | Description |
200
+ |--------|-------------|
201
+ | `X-Ape-Complete` | `1` if upload finished, `0` if still streaming |
202
+ | `X-Ape-Total-Received` | Bytes received so far |
203
+
162
204
  ---
163
205
 
164
206
  ## HTTP Streaming Endpoints
@@ -221,3 +263,267 @@ The built-in polyfill implements:
221
263
  - Ping/pong heartbeats
222
264
  - Proper close handshake
223
265
  - Masking (client→server)
266
+
267
+ ---
268
+
269
+ ## 🌲 Forest: Distributed Mesh
270
+
271
+ **Forest** is api-ape's distributed coordination system for horizontal scaling. It routes messages between servers via a shared database, enabling you to run multiple api-ape instances behind a load balancer.
272
+
273
+ ### Quick Start
274
+
275
+ ```js
276
+ const ape = require('api-ape');
277
+ const { createClient } = require('redis');
278
+
279
+ const redis = createClient();
280
+ await redis.connect();
281
+
282
+ // Join the mesh — pass any supported database client
283
+ ape.joinVia(redis);
284
+
285
+ // Graceful shutdown
286
+ process.on('SIGINT', async () => {
287
+ await ape.leaveCluster();
288
+ process.exit(0);
289
+ });
290
+ ```
291
+
292
+ ### The Problem Forest Solves
293
+
294
+ Without coordination, each server only knows about its own connected clients:
295
+
296
+ ```
297
+ Load Balancer
298
+
299
+ ┌────────────┼────────────┐
300
+ │ │ │
301
+ Server A Server B Server C
302
+ client-1 client-2 client-3
303
+ ```
304
+
305
+ If Server A wants to send a message to `client-2`, it doesn't know where `client-2` is connected.
306
+
307
+ **Naive solutions:**
308
+ - **Broadcast to all servers** — O(n) messages, doesn't scale
309
+ - **Sticky sessions** — Complex LB config, no failover
310
+
311
+ **Forest's solution:**
312
+ - **Direct routing** — Lookup `clientId → serverId`, push only to that server. O(1).
313
+
314
+ ### How It Works
315
+
316
+ Forest uses two database primitives:
317
+
318
+ | Primitive | Purpose | Example |
319
+ |-----------|---------|---------|
320
+ | **Lookup Table** | Maps `clientId → serverId` | Redis key, Postgres row |
321
+ | **Channels** | Real-time message push | Redis PUB/SUB, Postgres NOTIFY |
322
+
323
+ #### Message Flow
324
+
325
+ ```
326
+ Server A: "Send message to client-2"
327
+
328
+
329
+ 1. Check local clients → not found
330
+
331
+
332
+ 2. lookup.read("client-2") → "srv-B"
333
+
334
+
335
+ 3. channels.push("srv-B", { destClientId: "client-2", ... })
336
+
337
+
338
+ Database (Redis/Postgres/Mongo/etc)
339
+
340
+
341
+ Server B: Receives message, delivers to client-2
342
+ ```
343
+
344
+ ### Supported Backends
345
+
346
+ | Backend | How to Connect | Channels | Lookup | Ideal For |
347
+ |---------|---------------|----------|--------|-----------|
348
+ | **Redis** | `createClient()` | PUB/SUB | Key-value | Most deployments; fastest |
349
+ | **MongoDB** | `new MongoClient()` | Change Streams | Collection | Mongo-native stacks |
350
+ | **PostgreSQL** | `new pg.Pool()` | LISTEN/NOTIFY | Table | SQL shops |
351
+ | **Supabase** | `createClient()` | Realtime | Table | Supabase users |
352
+ | **Firebase** | `getDatabase()` | Native push | JSON tree | Serverless/edge |
353
+
354
+ ### API Reference
355
+
356
+ #### `ape.joinVia(client, options?)`
357
+
358
+ Join the distributed mesh.
359
+
360
+ ```js
361
+ ape.joinVia(redis);
362
+ ape.joinVia(redis, {
363
+ namespace: 'myapp', // Key/table prefix (default: 'ape')
364
+ serverId: 'srv-west-1' // Custom server ID (default: auto-generated)
365
+ });
366
+ ```
367
+
368
+ | Option | Type | Default | Description |
369
+ |--------|------|---------|-------------|
370
+ | `namespace` | `string` | `'ape'` | Prefix for all keys/tables |
371
+ | `serverId` | `string` | Auto-generated | Unique ID for this server instance |
372
+
373
+ #### `ape.leaveCluster()`
374
+
375
+ Gracefully leave the mesh. Removes client mappings and unsubscribes from channels.
376
+
377
+ ```js
378
+ await ape.leaveCluster();
379
+ ```
380
+
381
+ ### Namespacing
382
+
383
+ Forest creates its own database objects with your namespace prefix:
384
+
385
+ | Backend | Created Objects |
386
+ |---------|----------------|
387
+ | **Redis** | `ape:client:{id}`, `ape:channel:{serverId}`, `ape:channel:ALL` |
388
+ | **MongoDB** | Database: `ape_cluster`, Collections: `clients`, `events` |
389
+ | **PostgreSQL** | Tables: `ape_clients`, Channel: `ape_events` |
390
+ | **Supabase** | Table: `ape_clients` (must create), Realtime channels |
391
+ | **Firebase** | Paths: `/ape/clients/*`, `/ape/channels/*` |
392
+
393
+ ### Custom Adapters
394
+
395
+ For unsupported databases or testing, implement the adapter interface:
396
+
397
+ ```js
398
+ ape.joinVia({
399
+ async join(serverId) {
400
+ // Subscribe to channels, register this server
401
+ },
402
+
403
+ async leave() {
404
+ // Unsubscribe, cleanup client mappings
405
+ },
406
+
407
+ lookup: {
408
+ async add(clientId) {
409
+ // Map clientId → this server
410
+ },
411
+ async read(clientId) {
412
+ // Return serverId or null
413
+ },
414
+ async remove(clientId) {
415
+ // Delete mapping (must own it)
416
+ }
417
+ },
418
+
419
+ channels: {
420
+ async push(serverId, message) {
421
+ // Send to server's channel ("" = broadcast)
422
+ },
423
+ async pull(serverId, handler) {
424
+ // Subscribe to channel
425
+ // handler(message, senderServerId)
426
+ return async () => { /* unsubscribe */ };
427
+ }
428
+ }
429
+ });
430
+ ```
431
+
432
+ ### Lifecycle
433
+
434
+ | Event | What Happens |
435
+ |-------|-------------|
436
+ | **Server joins** | `join(serverId)` — subscribe to channels |
437
+ | **Client connects** | `lookup.add(clientId)` — register mapping |
438
+ | **Message to remote client** | `lookup.read()` → `channels.push()` |
439
+ | **Broadcast** | `channels.push('')` — to ALL channel |
440
+ | **Client disconnects** | `lookup.remove(clientId)` |
441
+ | **Server shuts down** | `leave()` — cleanup everything |
442
+
443
+ ### Crash Recovery
444
+
445
+ `clientId` is ephemeral — generated fresh on each connection. If a server crashes:
446
+
447
+ 1. Orphaned client mappings remain (stale)
448
+ 2. Clients reconnect with new `clientId` to another server
449
+ 3. New mappings are created; old ones are harmless
450
+ 4. Optional: Use Redis `EXPIRE` or DB TTL indexes for cleanup
451
+
452
+ ### Example: Multi-Server Chat
453
+
454
+ **Server A (port 3001):**
455
+ ```js
456
+ const ape = require('api-ape');
457
+ const redis = createClient();
458
+ await redis.connect();
459
+
460
+ ape(server, { where: 'api' });
461
+ ape.joinVia(redis, { serverId: 'srv-a' });
462
+
463
+ server.listen(3001);
464
+ ```
465
+
466
+ **Server B (port 3002):**
467
+ ```js
468
+ const ape = require('api-ape');
469
+ const redis = createClient();
470
+ await redis.connect();
471
+
472
+ ape(server, { where: 'api' });
473
+ ape.joinVia(redis, { serverId: 'srv-b' });
474
+
475
+ server.listen(3002);
476
+ ```
477
+
478
+ **Controller (`api/chat.js`):**
479
+ ```js
480
+ module.exports = function(message) {
481
+ // Broadcasts across ALL servers automatically
482
+ this.broadcastOthers('chat', {
483
+ from: this.clientId,
484
+ message
485
+ });
486
+ return { sent: true };
487
+ };
488
+ ```
489
+
490
+ Now clients connected to different servers can chat with each other seamlessly.
491
+
492
+ ### Performance Considerations
493
+
494
+ | Concern | Recommendation |
495
+ |---------|---------------|
496
+ | **Lookup latency** | Use Redis for sub-ms lookups |
497
+ | **Message throughput** | Redis PUB/SUB handles millions/sec |
498
+ | **Stale mappings** | Set TTL/EXPIRE on client keys |
499
+ | **Large payloads** | Postgres NOTIFY has 8KB limit |
500
+ | **Change Stream lag** | MongoDB may have slight delay |
501
+
502
+ ### Debugging
503
+
504
+ Forest logs key operations:
505
+
506
+ ```
507
+ 🔌 APE: Detected redis adapter (serverId: X7K9MWPA)
508
+ ✅ Redis adapter: joined as X7K9MWPA
509
+ 📍 Redis adapter: registered client abc123 -> X7K9MWPA
510
+ 📤 Redis adapter: pushed to server Y8M2ZPQR
511
+ 📢 Redis adapter: broadcast to all servers
512
+ 🔴 Redis adapter: leaving, cleaning up 3 clients
513
+ ```
514
+
515
+ ---
516
+
517
+ ## Adapter Files
518
+
519
+ See detailed adapter implementations in [`server/adapters/`](adapters/):
520
+
521
+ | File | Description |
522
+ |------|-------------|
523
+ | `index.js` | Auto-detects database type, creates adapter |
524
+ | `redis.js` | Redis PUB/SUB adapter |
525
+ | `mongo.js` | MongoDB Change Streams adapter |
526
+ | `postgres.js` | PostgreSQL LISTEN/NOTIFY adapter |
527
+ | `supabase.js` | Supabase Realtime adapter |
528
+ | `firebase.js` | Firebase RTDB adapter |
529
+ | `README.md` | Quick reference for all adapters |