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.
- package/README.md +91 -19
- 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 +229 -21
- package/index.js +15 -0
- package/package.json +2 -2
- package/server/README.md +338 -6
- 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/client.js +299 -0
- package/server/index.js +29 -6
- 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
|
@@ -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 };
|
package/server/client.js
ADDED
|
@@ -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
|
-
*
|
|
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,
|
|
21
|
+
const { broadcast, clients } = require('./lib/broadcast')
|
|
22
|
+
const api = require('./client')
|
|
8
23
|
|
|
9
|
-
// Attach broadcast utilities to the
|
|
24
|
+
// Attach broadcast utilities to the ape function
|
|
10
25
|
ape.broadcast = broadcast
|
|
11
|
-
ape.
|
|
12
|
-
|
|
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
|