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/README.md +88 -17
- package/client/browser.js +7 -7
- package/client/connectSocket.js +257 -22
- package/client/index.js +3 -3
- package/dist/ape.js +1 -1
- package/dist/ape.js.map +3 -3
- package/dist/api-ape.min.js +1 -1
- package/dist/api-ape.min.js.map +3 -3
- package/index.d.ts +183 -19
- package/package.json +2 -2
- package/server/README.md +311 -5
- package/server/adapters/README.md +275 -0
- package/server/adapters/firebase.js +172 -0
- package/server/adapters/index.js +144 -0
- package/server/adapters/mongo.js +161 -0
- package/server/adapters/postgres.js +177 -0
- package/server/adapters/redis.js +154 -0
- package/server/adapters/supabase.js +199 -0
- package/server/index.js +3 -3
- package/server/lib/broadcast.js +115 -49
- package/server/lib/bun.js +4 -4
- package/server/lib/fileTransfer.js +129 -0
- package/server/lib/longPolling.js +22 -13
- package/server/lib/main.js +40 -8
- package/server/lib/wiring.js +23 -19
- package/server/socket/receive.js +46 -0
- package/server/socket/send.js +7 -0
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
|
|
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
|
|
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
|
|
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
|
-
|
|
91
|
+
onDisconnect?: () => void
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
/**
|
|
132
|
-
export
|
|
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
|
-
/**
|
|
165
|
-
|
|
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
|
-
|
|
369
|
+
setOnReceiver: SetOnReceiver
|
|
225
370
|
/** Subscribe to connection state changes. Returns unsubscribe function. */
|
|
226
371
|
onConnectionChange: (handler: (state: ConnectionState) => void) => () => void
|
|
227
|
-
/**
|
|
228
|
-
|
|
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
|
|
260
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
58
|
+
onConnect: (socket, req, send) => ({
|
|
51
59
|
embed: { userId: req.session?.userId },
|
|
52
|
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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 |
|