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.
@@ -0,0 +1,275 @@
1
+ # APE Cluster Adapters
2
+
3
+ Connect multiple api-ape server instances via a shared database for horizontal scaling.
4
+
5
+ ## Quick Start
6
+
7
+ ```js
8
+ import ape from 'api-ape/server';
9
+ import { createClient } from 'redis';
10
+
11
+ // Connect to your database
12
+ const redis = createClient();
13
+ await redis.connect();
14
+
15
+ // Join the cluster — APE creates its own namespace
16
+ ape.joinVia(redis);
17
+ ```
18
+
19
+ That's it. APE will:
20
+ - Detect the database type (Redis, MongoDB, PostgreSQL)
21
+ - Create namespaced keys/tables (`ape:*` or `ape_*`)
22
+ - Route messages between servers automatically
23
+
24
+ ---
25
+
26
+ ## How It Works
27
+
28
+ ```
29
+ ┌─────────────┐ ┌─────────────┐
30
+ │ Server A │ │ Server B │
31
+ │ client-1 │ │ client-2 │
32
+ └──────┬──────┘ └──────▲──────┘
33
+ │ │
34
+ │ 1. sendTo("client-2") │
35
+ │ → lookup.read("client-2") │
36
+ │ → returns "srv-B" │
37
+ │ │
38
+ │ 2. channels.push("srv-B", msg) │
39
+ └──────────┬───────────────────────┘
40
+
41
+ ┌──────▼──────┐
42
+ │ Database │
43
+ │ (message │
44
+ │ bus) │
45
+ └─────────────┘
46
+ ```
47
+
48
+ Messages are routed **directly** to the server hosting the client. No broadcast spam.
49
+
50
+ ---
51
+
52
+ ## Adapter Interface
53
+
54
+ All adapters implement this interface:
55
+
56
+ ```ts
57
+ interface AdapterInstance {
58
+ // Lifecycle
59
+ join(serverId: string): Promise<void>;
60
+ leave(): Promise<void>;
61
+
62
+ // Client → Server mapping
63
+ lookup: {
64
+ add(clientId: string): Promise<void>;
65
+ read(clientId: string): Promise<string | null>;
66
+ remove(clientId: string): Promise<void>;
67
+ };
68
+
69
+ // Inter-server messaging
70
+ channels: {
71
+ push(serverId: string, message: object): Promise<void>;
72
+ pull(serverId: string, handler: (msg, senderServerId) => void): Promise<() => void>;
73
+ };
74
+ }
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Supported Databases
80
+
81
+ ### Redis (Recommended)
82
+
83
+ Best performance. Uses PUB/SUB for real-time messaging.
84
+
85
+ ```js
86
+ import { createClient } from 'redis';
87
+
88
+ const redis = createClient({ url: 'redis://localhost:6379' });
89
+ await redis.connect();
90
+
91
+ ape.joinVia(redis);
92
+ ```
93
+
94
+ **Keys created:**
95
+ - `ape:client:{clientId}` — client→server mapping
96
+ - `ape:channel:{serverId}` — PUB/SUB channel
97
+ - `ape:channel:ALL` — broadcast channel
98
+
99
+ ---
100
+
101
+ ### MongoDB
102
+
103
+ Uses Change Streams for real-time push (requires replica set).
104
+
105
+ ```js
106
+ import { MongoClient } from 'mongodb';
107
+
108
+ const mongo = new MongoClient('mongodb://localhost:27017');
109
+ await mongo.connect();
110
+
111
+ ape.joinVia(mongo);
112
+ ```
113
+
114
+ **Database/Collections created:**
115
+ - Database: `ape_cluster`
116
+ - Collection: `clients` — client→server mapping
117
+ - Collection: `events` — message bus (change streams)
118
+
119
+ ---
120
+
121
+ ### PostgreSQL
122
+
123
+ Uses LISTEN/NOTIFY for real-time messaging.
124
+
125
+ ```js
126
+ import pg from 'pg';
127
+
128
+ const pool = new pg.Pool({ connectionString: 'postgres://localhost/mydb' });
129
+
130
+ ape.joinVia(pool);
131
+ ```
132
+
133
+ **Database/Tables created:**
134
+ - Database: `ape_cluster` (or uses existing)
135
+ - Table: `clients` — client→server mapping
136
+ - Channel: `ape_events` — LISTEN/NOTIFY channel
137
+
138
+ ---
139
+
140
+ ### Supabase
141
+
142
+ Uses Supabase Realtime for push messaging. Simple setup if you're already using Supabase.
143
+
144
+ ```js
145
+ import { createClient } from '@supabase/supabase-js';
146
+
147
+ const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
148
+
149
+ ape.joinVia(supabase);
150
+ ```
151
+
152
+ **Requirements:**
153
+ - Create table: `ape_clients (client_id TEXT PRIMARY KEY, server_id TEXT, updated_at TIMESTAMP)`
154
+ - Enable Realtime on your project
155
+
156
+ ---
157
+
158
+ ### Firebase Realtime Database
159
+
160
+ Native real-time push. Perfect for serverless and edge deployments.
161
+
162
+ ```js
163
+ import { initializeApp } from 'firebase-admin/app';
164
+ import { getDatabase } from 'firebase-admin/database';
165
+
166
+ const app = initializeApp();
167
+ const database = getDatabase(app);
168
+
169
+ ape.joinVia(database);
170
+ ```
171
+
172
+ **Paths created:**
173
+ - `/ape/clients/{clientId}` — client→server mapping
174
+ - `/ape/channels/{serverId}` — message channels
175
+ - `/ape/channels/ALL` — broadcast channel
176
+
177
+ ---
178
+
179
+ ## Custom Adapters
180
+
181
+ For other databases or testing, pass your own adapter:
182
+
183
+ ```js
184
+ ape.joinVia({
185
+ async join(serverId) {
186
+ // Subscribe to channels, register server
187
+ },
188
+
189
+ async leave() {
190
+ // Cleanup subscriptions, remove client mappings
191
+ },
192
+
193
+ lookup: {
194
+ async add(clientId) {
195
+ // Map clientId → this serverId
196
+ },
197
+ async read(clientId) {
198
+ // Return serverId or null
199
+ },
200
+ async remove(clientId) {
201
+ // Delete mapping (only if we own it)
202
+ }
203
+ },
204
+
205
+ channels: {
206
+ async push(serverId, message) {
207
+ // Send to serverId's channel ("" = broadcast)
208
+ },
209
+ async pull(serverId, handler) {
210
+ // Subscribe to serverId's channel
211
+ // handler(message, senderServerId)
212
+ return async () => { /* unsubscribe */ };
213
+ }
214
+ }
215
+ });
216
+ ```
217
+
218
+ ---
219
+
220
+ ## Options
221
+
222
+ ```js
223
+ ape.joinVia(redis, {
224
+ namespace: 'myapp', // Default: 'ape'
225
+ serverId: 'srv-custom' // Default: auto-generated UUID
226
+ });
227
+ ```
228
+
229
+ ---
230
+
231
+ ## Lifecycle
232
+
233
+ | Event | Adapter Action |
234
+ |-------|---------------|
235
+ | Server starts | `join(serverId)` — subscribe to channels |
236
+ | Client connects | `lookup.add(clientId)` — register mapping |
237
+ | Client disconnects | `lookup.remove(clientId)` — delete mapping |
238
+ | Server shutdown | `leave()` — cleanup all owned mappings |
239
+
240
+ ### Graceful Shutdown
241
+
242
+ ```js
243
+ process.on('SIGINT', async () => {
244
+ await ape.leaveCluster();
245
+ process.exit(0);
246
+ });
247
+ ```
248
+
249
+ ---
250
+
251
+ ## Message Format
252
+
253
+ Messages sent via `channels.push`:
254
+
255
+ ```js
256
+ // Direct message
257
+ {
258
+ destClientId: 'user-123',
259
+ type: 'chat',
260
+ data: { text: 'Hello!' }
261
+ }
262
+
263
+ // Broadcast
264
+ {
265
+ type: 'system',
266
+ data: { notice: 'Maintenance in 5 min' }
267
+ }
268
+
269
+ // Broadcast excluding sender
270
+ {
271
+ type: 'chat',
272
+ data: { text: 'Hello everyone!' },
273
+ excludeClientId: 'user-456'
274
+ }
275
+ ```
@@ -0,0 +1,172 @@
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.
8
+ */
9
+
10
+ /**
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>}
17
+ */
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);
39
+ }
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);
44
+ }
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
+ }
166
+ }
167
+ };
168
+
169
+ return adapter;
170
+ }
171
+
172
+ module.exports = { createFirebaseAdapter };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * APE Cluster Adapters
3
+ *
4
+ * Detect database type and create appropriate adapter for multi-server coordination.
5
+ *
6
+ * Usage:
7
+ * const adapter = await createAdapter(redisClient);
8
+ * const adapter = await createAdapter(mongoClient);
9
+ * const adapter = await createAdapter(pgPool);
10
+ * const adapter = await createAdapter(supabaseClient);
11
+ * const adapter = await createAdapter(firebaseDatabase);
12
+ * const adapter = await createAdapter(customAdapter);
13
+ */
14
+
15
+ const { randomBytes } = require('crypto');
16
+
17
+ // Generate short unique server ID
18
+ const B = b => [...b].map(v => '0123456789ABCDEFGHJKMNPQRSTVWXYZ'[v & 31]).join('');
19
+ const uuid = () => B(randomBytes(8));
20
+
21
+ /**
22
+ * Detect database type from client object
23
+ * @param {object} client - Database client
24
+ * @returns {'redis'|'mongo'|'postgres'|'supabase'|'firebase'|'custom'|null}
25
+ */
26
+ function detectClientType(client) {
27
+ if (!client) return null;
28
+
29
+ // Custom adapter - has our interface methods
30
+ if (typeof client.join === 'function' &&
31
+ typeof client.leave === 'function' &&
32
+ client.lookup && client.channels) {
33
+ return 'custom';
34
+ }
35
+
36
+ // Redis (node-redis or ioredis)
37
+ if (typeof client.duplicate === 'function' &&
38
+ (typeof client.publish === 'function' || typeof client.PUBLISH === 'function')) {
39
+ return 'redis';
40
+ }
41
+
42
+ // MongoDB
43
+ if (typeof client.db === 'function' && client.constructor?.name === 'MongoClient') {
44
+ return 'mongo';
45
+ }
46
+
47
+ // PostgreSQL (pg.Pool)
48
+ if (typeof client.query === 'function' && typeof client.connect === 'function') {
49
+ return 'postgres';
50
+ }
51
+
52
+ // Supabase (has .from() for tables and .channel() for realtime)
53
+ if (typeof client.from === 'function' && typeof client.channel === 'function') {
54
+ return 'supabase';
55
+ }
56
+
57
+ // Firebase Realtime Database (has .ref() method)
58
+ if (typeof client.ref === 'function' &&
59
+ (typeof client.goOnline === 'function' || client.app)) {
60
+ return 'firebase';
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Create adapter from database client
68
+ * @param {object} client - Database client or custom adapter
69
+ * @param {object} opts - Options
70
+ * @param {string} [opts.namespace='ape'] - Key/table prefix
71
+ * @param {string} [opts.serverId] - Server ID (auto-generated if not provided)
72
+ * @returns {Promise<AdapterInstance>}
73
+ */
74
+ async function createAdapter(client, opts = {}) {
75
+ const type = detectClientType(client);
76
+ const serverId = opts.serverId || uuid();
77
+ const namespace = opts.namespace || 'ape';
78
+
79
+ if (!type) {
80
+ throw new Error(
81
+ 'Unable to detect database type. Supported: Redis, MongoDB, PostgreSQL, Supabase, Firebase, or custom adapter.'
82
+ );
83
+ }
84
+
85
+ console.log(`🔌 APE: Detected ${type} adapter (serverId: ${serverId})`);
86
+
87
+ switch (type) {
88
+ case 'custom':
89
+ return wrapCustomAdapter(client, serverId);
90
+
91
+ case 'redis':
92
+ const { createRedisAdapter } = require('./redis');
93
+ return createRedisAdapter(client, { serverId, namespace });
94
+
95
+ case 'mongo':
96
+ const { createMongoAdapter } = require('./mongo');
97
+ return createMongoAdapter(client, { serverId, namespace });
98
+
99
+ case 'postgres':
100
+ const { createPostgresAdapter } = require('./postgres');
101
+ return createPostgresAdapter(client, { serverId, namespace });
102
+
103
+ case 'supabase':
104
+ const { createSupabaseAdapter } = require('./supabase');
105
+ return createSupabaseAdapter(client, { serverId, namespace });
106
+
107
+ case 'firebase':
108
+ const { createFirebaseAdapter } = require('./firebase');
109
+ return createFirebaseAdapter(client, { serverId, namespace });
110
+
111
+ default:
112
+ throw new Error(`Unknown adapter type: ${type}`);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Attach serverId to custom adapter
118
+ * @param {object} adapter - Custom adapter object
119
+ * @param {string} serverId - Server ID
120
+ * @returns {AdapterInstance}
121
+ */
122
+ function wrapCustomAdapter(adapter, serverId) {
123
+ // Wrap to ensure consistent interface and default serverId
124
+ return {
125
+ get serverId() { return serverId; },
126
+ join: (id) => adapter.join(id || serverId),
127
+ leave: () => adapter.leave(),
128
+ lookup: {
129
+ add: (clientId) => adapter.lookup.add(clientId),
130
+ read: (clientId) => adapter.lookup.read(clientId),
131
+ remove: (clientId) => adapter.lookup.remove(clientId)
132
+ },
133
+ channels: {
134
+ push: (targetServerId, message) => adapter.channels.push(targetServerId, message),
135
+ pull: (targetServerId, handler) => adapter.channels.pull(targetServerId, handler)
136
+ }
137
+ };
138
+ }
139
+
140
+ module.exports = {
141
+ createAdapter,
142
+ detectClientType,
143
+ uuid
144
+ };