api-ape 2.2.3 → 3.0.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,154 @@
1
+ /**
2
+ * Redis Adapter for APE Cluster
3
+ *
4
+ * Uses Redis PUB/SUB for real-time inter-server messaging.
5
+ * Client mappings stored as simple key-value pairs.
6
+ */
7
+
8
+ /**
9
+ * Create Redis adapter
10
+ * @param {object} redis - Redis client (node-redis or ioredis)
11
+ * @param {object} opts
12
+ * @param {string} opts.serverId - This server's unique ID
13
+ * @param {string} [opts.namespace='ape'] - Key prefix
14
+ * @returns {Promise<AdapterInstance>}
15
+ */
16
+ async function createRedisAdapter(redis, { serverId, namespace = 'ape' }) {
17
+ if (!serverId) throw new Error('serverId required');
18
+
19
+ // State machine: INIT -> JOINED -> LEFT
20
+ let state = 'INIT';
21
+ const ownedClients = new Set();
22
+ const handlers = new Map();
23
+
24
+ // Create dedicated pub/sub connections
25
+ const pub = redis.duplicate();
26
+ const sub = redis.duplicate();
27
+
28
+ // Key helpers
29
+ const key = {
30
+ client: (id) => `${namespace}:client:${id}`,
31
+ channel: (id) => `${namespace}:channel:${id || 'ALL'}`,
32
+ };
33
+
34
+ // Connect pub/sub clients
35
+ if (typeof pub.connect === 'function' && pub.isOpen === false) {
36
+ await pub.connect();
37
+ }
38
+ if (typeof sub.connect === 'function' && sub.isOpen === false) {
39
+ await sub.connect();
40
+ }
41
+
42
+ // Handle incoming messages (node-redis v4 style)
43
+ if (typeof sub.on === 'function') {
44
+ sub.on('message', (channel, message) => {
45
+ try {
46
+ const data = JSON.parse(message);
47
+ // Find matching handler
48
+ for (const [pattern, handler] of handlers) {
49
+ if (channel === key.channel(pattern) || channel === key.channel('')) {
50
+ handler(data, data._senderServerId || serverId);
51
+ }
52
+ }
53
+ } catch (e) {
54
+ console.error('📛 Redis adapter: failed to parse message', e.message);
55
+ }
56
+ });
57
+ }
58
+
59
+ const adapter = {
60
+ get serverId() { return serverId; },
61
+
62
+ async join(id) {
63
+ const sid = id || serverId;
64
+ if (!sid?.trim()) throw new Error('serverId required');
65
+ if (state === 'JOINED') throw new Error('already joined');
66
+ if (state === 'LEFT') throw new Error('cannot rejoin after leave');
67
+
68
+ // Subscribe to this server's channel + broadcast channel
69
+ await sub.subscribe(key.channel(sid));
70
+ await sub.subscribe(key.channel(''));
71
+
72
+ state = 'JOINED';
73
+ console.log(`✅ Redis adapter: joined as ${sid}`);
74
+ },
75
+
76
+ async leave() {
77
+ if (state !== 'JOINED') return;
78
+ state = 'LEFT';
79
+
80
+ console.log(`🔴 Redis adapter: leaving, cleaning up ${ownedClients.size} clients`);
81
+
82
+ // Remove all owned client mappings
83
+ for (const clientId of ownedClients) {
84
+ try {
85
+ await pub.del(key.client(clientId));
86
+ } catch (e) {
87
+ console.error(`📛 Redis adapter: failed to remove client ${clientId}`, e.message);
88
+ }
89
+ }
90
+ ownedClients.clear();
91
+
92
+ // Unsubscribe and disconnect
93
+ try {
94
+ await sub.unsubscribe();
95
+ await pub.quit();
96
+ await sub.quit();
97
+ } catch (e) {
98
+ // Ignore disconnect errors
99
+ }
100
+ },
101
+
102
+ lookup: {
103
+ async add(clientId) {
104
+ await pub.set(key.client(clientId), serverId);
105
+ ownedClients.add(clientId);
106
+ console.log(`📍 Redis adapter: registered client ${clientId} -> ${serverId}`);
107
+ },
108
+
109
+ async read(clientId) {
110
+ const result = await pub.get(key.client(clientId));
111
+ return result || null;
112
+ },
113
+
114
+ async remove(clientId) {
115
+ if (!ownedClients.has(clientId)) {
116
+ throw new Error(`not owner: cannot remove client ${clientId}`);
117
+ }
118
+ await pub.del(key.client(clientId));
119
+ ownedClients.delete(clientId);
120
+ console.log(`🗑️ Redis adapter: removed client ${clientId}`);
121
+ }
122
+ },
123
+
124
+ channels: {
125
+ async push(targetServerId, message) {
126
+ const channel = key.channel(targetServerId);
127
+ const payload = JSON.stringify({
128
+ ...message,
129
+ _senderServerId: serverId
130
+ });
131
+ await pub.publish(channel, payload);
132
+
133
+ if (targetServerId) {
134
+ console.log(`📤 Redis adapter: pushed to server ${targetServerId}`);
135
+ } else {
136
+ console.log(`📢 Redis adapter: broadcast to all servers`);
137
+ }
138
+ },
139
+
140
+ async pull(targetServerId, handler) {
141
+ handlers.set(targetServerId || '', handler);
142
+
143
+ // Return unsubscribe function
144
+ return async () => {
145
+ handlers.delete(targetServerId || '');
146
+ };
147
+ }
148
+ }
149
+ };
150
+
151
+ return adapter;
152
+ }
153
+
154
+ module.exports = { createRedisAdapter };
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Supabase Adapter for APE Cluster
3
+ *
4
+ * Uses Supabase Realtime for inter-server messaging.
5
+ * Client mappings stored in a dedicated table.
6
+ *
7
+ * Supabase is Postgres under the hood with a simpler Realtime API.
8
+ */
9
+
10
+ /**
11
+ * Create Supabase adapter
12
+ * @param {SupabaseClient} supabase - Supabase client from @supabase/supabase-js
13
+ * @param {object} opts
14
+ * @param {string} opts.serverId - This server's unique ID
15
+ * @param {string} [opts.namespace='ape'] - Table prefix
16
+ * @returns {Promise<AdapterInstance>}
17
+ */
18
+ async function createSupabaseAdapter(supabase, { 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
+ let realtimeChannel = null;
26
+
27
+ // Table names
28
+ const clientsTable = `${namespace}_clients`;
29
+ const eventsTable = `${namespace}_events`;
30
+
31
+ // Ensure tables exist (Supabase requires pre-created tables via migrations)
32
+ // This is a validation check, not creation
33
+ async function validateTables() {
34
+ const { error: clientsError } = await supabase
35
+ .from(clientsTable)
36
+ .select('client_id')
37
+ .limit(1);
38
+
39
+ if (clientsError && clientsError.code === '42P01') {
40
+ throw new Error(
41
+ `Table "${clientsTable}" does not exist. ` +
42
+ `Create it with: CREATE TABLE ${clientsTable} (client_id TEXT PRIMARY KEY, server_id TEXT NOT NULL);`
43
+ );
44
+ }
45
+ }
46
+
47
+ const adapter = {
48
+ get serverId() { return serverId; },
49
+
50
+ async join(id) {
51
+ const sid = id || serverId;
52
+ if (!sid?.trim()) throw new Error('serverId required');
53
+ if (state === 'JOINED') throw new Error('already joined');
54
+ if (state === 'LEFT') throw new Error('cannot rejoin after leave');
55
+
56
+ await validateTables();
57
+
58
+ // Subscribe to Realtime channel for this server + broadcast
59
+ realtimeChannel = supabase
60
+ .channel(`${namespace}:${sid}`)
61
+ .on('broadcast', { event: 'message' }, ({ payload }) => {
62
+ const { targetServerId, message, senderServerId } = payload;
63
+
64
+ // Check if message is for us or broadcast
65
+ if (targetServerId === sid || targetServerId === '') {
66
+ const handler = handlers.get(targetServerId) || handlers.get('');
67
+ if (handler) {
68
+ handler(message, senderServerId);
69
+ }
70
+ }
71
+ })
72
+ .subscribe();
73
+
74
+ // Also subscribe to broadcast channel
75
+ supabase
76
+ .channel(`${namespace}:ALL`)
77
+ .on('broadcast', { event: 'message' }, ({ payload }) => {
78
+ const { message, senderServerId } = payload;
79
+ const handler = handlers.get('');
80
+ if (handler) {
81
+ handler(message, senderServerId);
82
+ }
83
+ })
84
+ .subscribe();
85
+
86
+ state = 'JOINED';
87
+ console.log(`✅ Supabase adapter: joined as ${sid}`);
88
+ },
89
+
90
+ async leave() {
91
+ if (state !== 'JOINED') return;
92
+ state = 'LEFT';
93
+
94
+ console.log(`🔴 Supabase adapter: leaving, cleaning up ${ownedClients.size} clients`);
95
+
96
+ // Unsubscribe from channels
97
+ if (realtimeChannel) {
98
+ await supabase.removeChannel(realtimeChannel);
99
+ realtimeChannel = null;
100
+ }
101
+
102
+ // Remove all owned client mappings
103
+ if (ownedClients.size > 0) {
104
+ const ids = Array.from(ownedClients);
105
+ await supabase
106
+ .from(clientsTable)
107
+ .delete()
108
+ .in('client_id', ids);
109
+ }
110
+ ownedClients.clear();
111
+ },
112
+
113
+ lookup: {
114
+ async add(clientId) {
115
+ const { error } = await supabase
116
+ .from(clientsTable)
117
+ .upsert({
118
+ client_id: clientId,
119
+ server_id: serverId,
120
+ updated_at: new Date().toISOString()
121
+ });
122
+
123
+ if (error) throw new Error(`Supabase lookup.add failed: ${error.message}`);
124
+
125
+ ownedClients.add(clientId);
126
+ console.log(`📍 Supabase adapter: registered client ${clientId} -> ${serverId}`);
127
+ },
128
+
129
+ async read(clientId) {
130
+ const { data, error } = await supabase
131
+ .from(clientsTable)
132
+ .select('server_id')
133
+ .eq('client_id', clientId)
134
+ .single();
135
+
136
+ if (error && error.code !== 'PGRST116') { // PGRST116 = not found
137
+ throw new Error(`Supabase lookup.read failed: ${error.message}`);
138
+ }
139
+
140
+ return data?.server_id || null;
141
+ },
142
+
143
+ async remove(clientId) {
144
+ if (!ownedClients.has(clientId)) {
145
+ throw new Error(`not owner: cannot remove client ${clientId}`);
146
+ }
147
+
148
+ const { error } = await supabase
149
+ .from(clientsTable)
150
+ .delete()
151
+ .eq('client_id', clientId);
152
+
153
+ if (error) throw new Error(`Supabase lookup.remove failed: ${error.message}`);
154
+
155
+ ownedClients.delete(clientId);
156
+ console.log(`🗑️ Supabase adapter: removed client ${clientId}`);
157
+ }
158
+ },
159
+
160
+ channels: {
161
+ async push(targetServerId, message) {
162
+ const channelName = targetServerId
163
+ ? `${namespace}:${targetServerId}`
164
+ : `${namespace}:ALL`;
165
+
166
+ const channel = supabase.channel(channelName);
167
+
168
+ await channel.send({
169
+ type: 'broadcast',
170
+ event: 'message',
171
+ payload: {
172
+ targetServerId: targetServerId || '',
173
+ senderServerId: serverId,
174
+ message
175
+ }
176
+ });
177
+
178
+ if (targetServerId) {
179
+ console.log(`📤 Supabase adapter: pushed to server ${targetServerId}`);
180
+ } else {
181
+ console.log(`📢 Supabase adapter: broadcast to all servers`);
182
+ }
183
+ },
184
+
185
+ async pull(targetServerId, handler) {
186
+ handlers.set(targetServerId || '', handler);
187
+
188
+ // Return unsubscribe function
189
+ return async () => {
190
+ handlers.delete(targetServerId || '');
191
+ };
192
+ }
193
+ }
194
+ };
195
+
196
+ return adapter;
197
+ }
198
+
199
+ module.exports = { createSupabaseAdapter };
@@ -0,0 +1,299 @@
1
+ /**
2
+ * api-ape Node.js client
3
+ *
4
+ * Mirrors the browser client API exactly - same usage on server and browser.
5
+ *
6
+ * Usage (identical to browser):
7
+ * import api from 'api-ape'
8
+ *
9
+ * api.message({ user: 'Bob', text: 'Hello!' })
10
+ * api.on('message', (data) => console.log(data))
11
+ * api.onConnectionChange((state) => console.log(state))
12
+ *
13
+ * Configuration:
14
+ * Set APE_SERVER environment variable to the WebSocket URL:
15
+ * APE_SERVER=ws://other-server:3000/api/ape node app.js
16
+ *
17
+ * Or call api.connect(url) before first use
18
+ */
19
+
20
+ const jss = require('../utils/jss')
21
+ const { WebSocket: WsPolyfill } = require('./lib/ws')
22
+
23
+ // Use native WebSocket if available (Node 22+), otherwise use polyfill
24
+ const WebSocket = globalThis.WebSocket || WsPolyfill
25
+
26
+ // Connection state enum
27
+ const ConnectionState = {
28
+ Disconnected: 'disconnected',
29
+ Connecting: 'connecting',
30
+ Connected: 'connected',
31
+ Closing: 'closing'
32
+ }
33
+
34
+ // Shared state (mirrors browser client)
35
+ let ws = null
36
+ let connectionState = ConnectionState.Disconnected
37
+ const connectionChangeListeners = []
38
+ const waitingOn = {}
39
+ const receiverArray = []
40
+ const ofTypesOb = {}
41
+ let queryCounter = 0
42
+ let bufferedCalls = []
43
+ let bufferedReceivers = []
44
+ let ready = false
45
+ let reconnectEnabled = true
46
+ let reconnectTimer = null
47
+ let serverUrl = process.env.APE_SERVER || null
48
+
49
+ const joinKey = '/'
50
+ const connectTimeout = 5000
51
+ const totalRequestTimeout = 10000
52
+
53
+ function notifyConnectionChange(newState) {
54
+ if (connectionState !== newState) {
55
+ connectionState = newState
56
+ connectionChangeListeners.forEach(fn => fn(newState))
57
+ }
58
+ }
59
+
60
+ function generateQueryId() {
61
+ return `q${Date.now().toString(36)}_${(queryCounter++).toString(36)}`
62
+ }
63
+
64
+ function connect(url) {
65
+ if (url) serverUrl = url
66
+
67
+ if (!serverUrl) {
68
+ console.warn('🦍 api-ape: No server URL configured. Set APE_SERVER env or call api.connect(url)')
69
+ return
70
+ }
71
+
72
+ if (ws && ws.readyState !== WebSocket.CLOSED) {
73
+ return
74
+ }
75
+
76
+ notifyConnectionChange(ConnectionState.Connecting)
77
+
78
+ ws = new WebSocket(serverUrl)
79
+
80
+ ws.onopen = () => {
81
+ ready = true
82
+ notifyConnectionChange(ConnectionState.Connected)
83
+
84
+ // Flush buffered receivers
85
+ bufferedReceivers.forEach(({ type, handler }) => {
86
+ setOnReceiver(type, handler)
87
+ })
88
+ bufferedReceivers = []
89
+
90
+ // Flush buffered calls
91
+ bufferedCalls.forEach(({ type, data, resolve, reject, createdAt, timer }) => {
92
+ clearTimeout(timer)
93
+ send(type, data, createdAt).then(resolve).catch(reject)
94
+ })
95
+ bufferedCalls = []
96
+ }
97
+
98
+ ws.onmessage = (event) => {
99
+ const msg = jss.parse(typeof event.data === 'string' ? event.data : event.data.toString())
100
+ const { err, type, queryId, data } = msg
101
+
102
+ // Response to a query
103
+ if (queryId && waitingOn[queryId]) {
104
+ waitingOn[queryId](err, data)
105
+ delete waitingOn[queryId]
106
+ return
107
+ }
108
+
109
+ // Broadcast message
110
+ if (ofTypesOb[type]) {
111
+ ofTypesOb[type].forEach(handler => handler({ err, type, data }))
112
+ }
113
+ receiverArray.forEach(handler => handler({ err, type, data }))
114
+ }
115
+
116
+ ws.onerror = (err) => {
117
+ console.error('🦍 api-ape client error:', err.message || err)
118
+ }
119
+
120
+ ws.onclose = () => {
121
+ ready = false
122
+ ws = null
123
+ notifyConnectionChange(ConnectionState.Disconnected)
124
+
125
+ if (reconnectEnabled && serverUrl) {
126
+ reconnectTimer = setTimeout(() => connect(), 1000)
127
+ }
128
+ }
129
+ }
130
+
131
+ function send(type, data, createdAt = Date.now()) {
132
+ const queryId = generateQueryId()
133
+
134
+ return new Promise((resolve, reject) => {
135
+ const timer = setTimeout(() => {
136
+ delete waitingOn[queryId]
137
+ reject(new Error(`Request timeout: ${type}`))
138
+ }, totalRequestTimeout)
139
+
140
+ waitingOn[queryId] = (err, result) => {
141
+ clearTimeout(timer)
142
+ if (err) {
143
+ reject(typeof err === 'string' ? new Error(err) : err)
144
+ } else {
145
+ resolve(result)
146
+ }
147
+ }
148
+
149
+ const message = jss.stringify({ type, data, queryId, createdAt })
150
+ ws.send(message)
151
+ })
152
+ }
153
+
154
+ function queueOrSend(type, data) {
155
+ if (ready && ws && ws.readyState === WebSocket.OPEN) {
156
+ return send(type, data)
157
+ }
158
+
159
+ // Queue the message
160
+ return new Promise((resolve, reject) => {
161
+ const createdAt = Date.now()
162
+ const timer = setTimeout(() => {
163
+ const idx = bufferedCalls.findIndex(m => m.createdAt === createdAt)
164
+ if (idx > -1) bufferedCalls.splice(idx, 1)
165
+ reject(new Error(`Connection timeout: ${type}`))
166
+ }, connectTimeout)
167
+
168
+ bufferedCalls.push({ type, data, resolve, reject, createdAt, timer })
169
+
170
+ // Ensure we're connecting
171
+ if (connectionState === ConnectionState.Disconnected && serverUrl) {
172
+ connect()
173
+ }
174
+ })
175
+ }
176
+
177
+ /**
178
+ * Subscribe to broadcasts from the server (same as browser api.on)
179
+ */
180
+ function on(type, handler) {
181
+ if (typeof type === 'function') {
182
+ handler = type
183
+ type = null
184
+ }
185
+
186
+ if (ready) {
187
+ setOnReceiver(type, handler)
188
+ } else {
189
+ bufferedReceivers.push({ type, handler })
190
+ if (serverUrl) connect()
191
+ }
192
+ }
193
+
194
+ function setOnReceiver(type, handler) {
195
+ if (type === null) {
196
+ receiverArray.push(handler)
197
+ } else {
198
+ if (!ofTypesOb[type]) ofTypesOb[type] = []
199
+ ofTypesOb[type].push(handler)
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Subscribe to connection state changes (same as browser api.onConnectionChange)
205
+ */
206
+ function onConnectionChange(handler) {
207
+ connectionChangeListeners.push(handler)
208
+ handler(connectionState)
209
+ return () => {
210
+ const idx = connectionChangeListeners.indexOf(handler)
211
+ if (idx > -1) connectionChangeListeners.splice(idx, 1)
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Create the sender proxy (mirrors browser client exactly)
217
+ */
218
+ const handler = {
219
+ get(target, prop) {
220
+ // Reserved properties - same as browser
221
+ if (prop === 'on') return on
222
+ if (prop === 'onConnectionChange') return onConnectionChange
223
+ if (prop === 'transport') return ready ? 'websocket' : null
224
+ if (prop === 'connect') return connect
225
+ if (prop === 'close') return close
226
+ if (prop === 'then' || prop === 'catch') return undefined // Not a Promise
227
+
228
+ // Return a function that either calls directly or buffers
229
+ const wrapperFn = function (a, b) {
230
+ let path = joinKey + prop, body
231
+ if (arguments.length === 2) {
232
+ path += a
233
+ body = b
234
+ } else {
235
+ body = a
236
+ }
237
+ return queueOrSend(path, body)
238
+ }
239
+ return new Proxy(wrapperFn, handler)
240
+ }
241
+ }
242
+
243
+ function close() {
244
+ reconnectEnabled = false
245
+ if (reconnectTimer) {
246
+ clearTimeout(reconnectTimer)
247
+ reconnectTimer = null
248
+ }
249
+ if (ws) {
250
+ notifyConnectionChange(ConnectionState.Closing)
251
+ ws.close()
252
+ }
253
+ }
254
+
255
+ // Create the proxy (same interface as browser senderProxy)
256
+ const api = new Proxy({}, handler)
257
+
258
+ // Define properties on the proxy (same as browser)
259
+ Object.defineProperty(api, 'on', {
260
+ value: on,
261
+ writable: false,
262
+ enumerable: false,
263
+ configurable: false
264
+ })
265
+
266
+ Object.defineProperty(api, 'onConnectionChange', {
267
+ value: onConnectionChange,
268
+ writable: false,
269
+ enumerable: false,
270
+ configurable: false
271
+ })
272
+
273
+ Object.defineProperty(api, 'connect', {
274
+ value: connect,
275
+ writable: false,
276
+ enumerable: false,
277
+ configurable: false
278
+ })
279
+
280
+ Object.defineProperty(api, 'close', {
281
+ value: close,
282
+ writable: false,
283
+ enumerable: false,
284
+ configurable: false
285
+ })
286
+
287
+ // Auto-connect if APE_SERVER is set
288
+ if (serverUrl) {
289
+ connect()
290
+ }
291
+
292
+ // Export the same interface as browser
293
+ module.exports = api
294
+ module.exports.default = api
295
+ module.exports.on = on
296
+ module.exports.onConnectionChange = onConnectionChange
297
+ module.exports.connect = connect
298
+ module.exports.close = close
299
+ module.exports.ConnectionState = ConnectionState
package/server/index.js CHANGED
@@ -1,14 +1,37 @@
1
1
  /**
2
2
  * api-ape server entry point
3
- * Exports the main ape function and broadcast utilities
3
+ *
4
+ * V3 Usage (100% identical on browser and server):
5
+ * import api from 'api-ape'
6
+ * api.hello('World') // Works same on browser AND server
7
+ * api.on('message', (data) => console.log(data))
8
+ *
9
+ * Server Setup:
10
+ * import api, { ape } from 'api-ape'
11
+ * ape(server, { where: 'api' }) // Start your server
12
+ *
13
+ * // Connect to another server (set APE_SERVER env or call api.connect)
14
+ * api.connect('ws://other-server:3000/api/ape')
15
+ * api.hello('World')
16
+ *
17
+ * Supports both CommonJS and ES Modules
4
18
  */
5
19
 
6
20
  const ape = require('./lib/main')
7
- const { broadcast, online, getClients } = require('./lib/broadcast')
21
+ const { broadcast, clients } = require('./lib/broadcast')
22
+ const api = require('./client')
8
23
 
9
- // Attach broadcast utilities to the main function for clean exports
24
+ // Attach broadcast utilities to the ape function
10
25
  ape.broadcast = broadcast
11
- ape.online = online
12
- ape.getClients = getClients
26
+ ape.clients = clients
27
+
28
+ // Default export: api client (same interface as browser)
29
+ module.exports = api
30
+
31
+ // Named exports
32
+ module.exports.ape = ape
33
+ module.exports.api = api
34
+
35
+
36
+
13
37
 
14
- module.exports = ape