openkbs-pulse 1.0.17 → 2.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/package.json +2 -2
- package/pulse.js +469 -313
- package/server.js +146 -23
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openkbs-pulse",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Real-time WebSocket SDK for OpenKBS",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Real-time WebSocket SDK for OpenKBS (Ably-compatible API)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "pulse.js",
|
|
7
7
|
"types": "pulse.d.ts",
|
package/pulse.js
CHANGED
|
@@ -1,494 +1,650 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenKBS Pulse
|
|
2
|
+
* OpenKBS Pulse - Client SDK (Ably-compatible API)
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* const
|
|
5
|
+
* const realtime = new Pulse.Realtime({ kbId: 'xxx', token: 'yyy' });
|
|
6
6
|
*
|
|
7
|
-
* const channel =
|
|
8
|
-
* channel.subscribe(
|
|
9
|
-
* channel.publish('
|
|
7
|
+
* const channel = realtime.channels.get('chat');
|
|
8
|
+
* channel.subscribe((message) => console.log(message.name, message.data));
|
|
9
|
+
* channel.publish('greeting', { text: 'Hello!' });
|
|
10
10
|
*
|
|
11
|
-
* channel.presence.subscribe((members) => console.log(
|
|
12
|
-
* channel.presence.enter({
|
|
11
|
+
* channel.presence.subscribe((members) => console.log(members));
|
|
12
|
+
* channel.presence.enter({ status: 'online' });
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
(function(global) {
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const ENDPOINTS = {
|
|
19
19
|
'us-east-1': 'wss://pulse.vpc1.us',
|
|
20
20
|
'eu-central-1': 'wss://pulse.vpc1.eu'
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
const DEFAULT_REGION = 'us-east-1';
|
|
24
23
|
const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000];
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
// ========================================================================
|
|
26
|
+
// Realtime Client (like Ably.Realtime)
|
|
27
|
+
// ========================================================================
|
|
28
|
+
|
|
29
|
+
class Realtime {
|
|
30
30
|
constructor(options = {}) {
|
|
31
|
-
if (!options.kbId)
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
if (!options.token) {
|
|
35
|
-
throw new Error('Pulse: token is required');
|
|
36
|
-
}
|
|
31
|
+
if (!options.kbId) throw new Error('kbId is required');
|
|
32
|
+
if (!options.token) throw new Error('token is required');
|
|
37
33
|
|
|
38
34
|
this.kbId = options.kbId;
|
|
39
35
|
this.token = options.token;
|
|
40
|
-
this.
|
|
41
|
-
this.region = options.region ||
|
|
42
|
-
this.endpoint = options.endpoint ||
|
|
36
|
+
this.clientId = options.clientId || null;
|
|
37
|
+
this.region = options.region || 'us-east-1';
|
|
38
|
+
this.endpoint = options.endpoint || ENDPOINTS[this.region] || ENDPOINTS['us-east-1'];
|
|
43
39
|
this.debug = options.debug || false;
|
|
44
40
|
|
|
45
|
-
|
|
41
|
+
// Connection state
|
|
42
|
+
this.connection = new Connection(this);
|
|
43
|
+
|
|
44
|
+
// Channels manager
|
|
45
|
+
this.channels = new Channels(this);
|
|
46
|
+
|
|
47
|
+
// Internal
|
|
46
48
|
this._ws = null;
|
|
47
49
|
this._reconnectAttempt = 0;
|
|
48
50
|
this._intentionalClose = false;
|
|
49
|
-
this._connectionState = 'disconnected';
|
|
50
|
-
this._stateListeners = [];
|
|
51
51
|
this._messageQueue = [];
|
|
52
52
|
|
|
53
|
-
// Auto-connect
|
|
53
|
+
// Auto-connect
|
|
54
54
|
if (options.autoConnect !== false) {
|
|
55
55
|
this.connect();
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/**
|
|
60
|
-
* Connect to Pulse WebSocket server
|
|
61
|
-
*/
|
|
62
59
|
connect() {
|
|
63
|
-
if (this._ws
|
|
64
|
-
this._log('Already connected');
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
60
|
+
if (this._ws?.readyState === WebSocket.OPEN) return;
|
|
67
61
|
|
|
68
62
|
this._intentionalClose = false;
|
|
69
|
-
this.
|
|
63
|
+
this.connection._setState('connecting');
|
|
70
64
|
|
|
71
|
-
const url = `${this.endpoint}?kbId=${this.kbId}&token=${this.token}
|
|
72
|
-
this._log('Connecting
|
|
65
|
+
const url = `${this.endpoint}?kbId=${this.kbId}&token=${this.token}`;
|
|
66
|
+
this._log('Connecting:', url);
|
|
73
67
|
|
|
74
68
|
try {
|
|
75
69
|
this._ws = new WebSocket(url);
|
|
76
|
-
this.
|
|
70
|
+
this._ws.onopen = () => this._onOpen();
|
|
71
|
+
this._ws.onclose = (e) => this._onClose(e);
|
|
72
|
+
this._ws.onerror = (e) => this._onError(e);
|
|
73
|
+
this._ws.onmessage = (e) => this._onMessage(e);
|
|
77
74
|
} catch (err) {
|
|
78
75
|
this._log('Connection error:', err);
|
|
79
|
-
this.
|
|
76
|
+
this.connection._setState('failed');
|
|
80
77
|
this._scheduleReconnect();
|
|
81
78
|
}
|
|
82
79
|
}
|
|
83
80
|
|
|
84
|
-
|
|
85
|
-
* Disconnect from server
|
|
86
|
-
*/
|
|
87
|
-
disconnect() {
|
|
81
|
+
close() {
|
|
88
82
|
this._intentionalClose = true;
|
|
89
|
-
this.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
this._ws = null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
this._setConnectionState('disconnected');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Get or create a channel
|
|
101
|
-
*/
|
|
102
|
-
channel(name) {
|
|
103
|
-
if (!this._channels[name]) {
|
|
104
|
-
this._channels[name] = new PulseChannel(this, name);
|
|
105
|
-
}
|
|
106
|
-
return this._channels[name];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Subscribe to connection state changes
|
|
111
|
-
*/
|
|
112
|
-
onStateChange(callback) {
|
|
113
|
-
this._stateListeners.push(callback);
|
|
114
|
-
// Immediately call with current state
|
|
115
|
-
callback(this._connectionState);
|
|
116
|
-
return () => {
|
|
117
|
-
this._stateListeners = this._stateListeners.filter(cb => cb !== callback);
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Get current connection state
|
|
123
|
-
*/
|
|
124
|
-
get state() {
|
|
125
|
-
return this._connectionState;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Check if connected
|
|
130
|
-
*/
|
|
131
|
-
get isConnected() {
|
|
132
|
-
return this._connectionState === 'connected';
|
|
83
|
+
this.connection._setState('closing');
|
|
84
|
+
this._ws?.close(1000, 'Client closed');
|
|
85
|
+
this._ws = null;
|
|
86
|
+
this.connection._setState('closed');
|
|
133
87
|
}
|
|
134
88
|
|
|
135
89
|
// Internal methods
|
|
136
90
|
|
|
137
|
-
|
|
138
|
-
this.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
this._setConnectionState('connected');
|
|
91
|
+
_onOpen() {
|
|
92
|
+
this._log('Connected');
|
|
93
|
+
this._reconnectAttempt = 0;
|
|
94
|
+
this.connection._setState('connected');
|
|
142
95
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
channel._resubscribe();
|
|
146
|
-
});
|
|
96
|
+
// Reattach all channels
|
|
97
|
+
this.channels._reattachAll();
|
|
147
98
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
99
|
+
// Flush queued messages
|
|
100
|
+
this._flushQueue();
|
|
101
|
+
}
|
|
151
102
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
103
|
+
_onClose(e) {
|
|
104
|
+
this._log('Disconnected:', e.code, e.reason);
|
|
105
|
+
this.connection._setState('disconnected');
|
|
155
106
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
this._ws.onerror = (err) => {
|
|
162
|
-
this._log('WebSocket error:', err);
|
|
163
|
-
this._setConnectionState('failed');
|
|
164
|
-
};
|
|
107
|
+
if (!this._intentionalClose) {
|
|
108
|
+
this._scheduleReconnect();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
165
111
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
112
|
+
_onError(err) {
|
|
113
|
+
this._log('Error:', err);
|
|
114
|
+
this.connection._setState('failed');
|
|
169
115
|
}
|
|
170
116
|
|
|
171
|
-
|
|
172
|
-
this._log('Received:', data);
|
|
117
|
+
_onMessage(e) {
|
|
118
|
+
this._log('Received:', e.data);
|
|
173
119
|
|
|
174
120
|
try {
|
|
175
|
-
const msg = JSON.parse(data);
|
|
176
|
-
|
|
177
|
-
// Handle server message format: {type: 'message', channel: 'x', data: {...}}
|
|
178
|
-
if (msg.type === 'message' && msg.data) {
|
|
179
|
-
// Route to the correct channel based on msg.channel
|
|
180
|
-
const channelName = msg.channel || this._defaultChannel;
|
|
181
|
-
const channel = this._channels[channelName];
|
|
182
|
-
if (channel) {
|
|
183
|
-
channel._handleMessage(msg.data);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Handle presence response: {type: 'presence', channel: 'x', action: 'sync', members: [...]}
|
|
188
|
-
if (msg.type === 'presence') {
|
|
189
|
-
const channelName = msg.channel || this._defaultChannel;
|
|
190
|
-
const channel = this._channels[channelName];
|
|
191
|
-
if (channel && channel.presence) {
|
|
192
|
-
channel.presence._handleMessage(msg);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Handle system messages
|
|
197
|
-
if (msg.type === 'error') {
|
|
198
|
-
this._log('Server error:', msg.error);
|
|
199
|
-
}
|
|
121
|
+
const msg = JSON.parse(e.data);
|
|
122
|
+
this._handleMessage(msg);
|
|
200
123
|
} catch (err) {
|
|
201
124
|
this._log('Parse error:', err);
|
|
202
125
|
}
|
|
203
126
|
}
|
|
204
127
|
|
|
128
|
+
_handleMessage(msg) {
|
|
129
|
+
const { action, channel } = msg;
|
|
130
|
+
|
|
131
|
+
// Route to channel
|
|
132
|
+
if (channel) {
|
|
133
|
+
const ch = this.channels._channels[channel];
|
|
134
|
+
if (ch) ch._handleMessage(msg);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Handle heartbeat response
|
|
138
|
+
if (action === 'heartbeat') {
|
|
139
|
+
this.connection._emit('heartbeat', msg.timestamp);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
205
143
|
_send(data) {
|
|
206
|
-
if (this._ws
|
|
144
|
+
if (this._ws?.readyState === WebSocket.OPEN) {
|
|
207
145
|
this._ws.send(JSON.stringify(data));
|
|
208
146
|
return true;
|
|
209
|
-
} else {
|
|
210
|
-
// Queue message for when connected
|
|
211
|
-
this._messageQueue.push(data);
|
|
212
|
-
return false;
|
|
213
147
|
}
|
|
148
|
+
this._messageQueue.push(data);
|
|
149
|
+
return false;
|
|
214
150
|
}
|
|
215
151
|
|
|
216
|
-
|
|
152
|
+
_flushQueue() {
|
|
217
153
|
while (this._messageQueue.length > 0) {
|
|
218
|
-
|
|
219
|
-
this._send(msg);
|
|
154
|
+
this._send(this._messageQueue.shift());
|
|
220
155
|
}
|
|
221
156
|
}
|
|
222
157
|
|
|
223
158
|
_scheduleReconnect() {
|
|
224
159
|
const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)];
|
|
225
160
|
this._reconnectAttempt++;
|
|
226
|
-
|
|
227
161
|
this._log(`Reconnecting in ${delay}ms (attempt ${this._reconnectAttempt})`);
|
|
228
|
-
this.
|
|
162
|
+
this.connection._setState('connecting');
|
|
163
|
+
setTimeout(() => !this._intentionalClose && this.connect(), delay);
|
|
164
|
+
}
|
|
229
165
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
166
|
+
_log(...args) {
|
|
167
|
+
if (this.debug) console.log('[Pulse]', ...args);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ========================================================================
|
|
172
|
+
// Connection (like Ably.Connection)
|
|
173
|
+
// ========================================================================
|
|
174
|
+
|
|
175
|
+
class Connection {
|
|
176
|
+
constructor(realtime) {
|
|
177
|
+
this._realtime = realtime;
|
|
178
|
+
this.state = 'initialized';
|
|
179
|
+
this._listeners = {};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
get id() {
|
|
183
|
+
return this._realtime._ws?.url || null;
|
|
235
184
|
}
|
|
236
185
|
|
|
237
|
-
|
|
238
|
-
if (this.
|
|
239
|
-
|
|
240
|
-
|
|
186
|
+
on(event, callback) {
|
|
187
|
+
if (!this._listeners[event]) this._listeners[event] = [];
|
|
188
|
+
this._listeners[event].push(callback);
|
|
189
|
+
return () => this.off(event, callback);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
off(event, callback) {
|
|
193
|
+
if (this._listeners[event]) {
|
|
194
|
+
this._listeners[event] = this._listeners[event].filter(cb => cb !== callback);
|
|
241
195
|
}
|
|
242
196
|
}
|
|
243
197
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
198
|
+
once(event, callback) {
|
|
199
|
+
const wrapper = (...args) => {
|
|
200
|
+
this.off(event, wrapper);
|
|
201
|
+
callback(...args);
|
|
202
|
+
};
|
|
203
|
+
this.on(event, wrapper);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_setState(state) {
|
|
207
|
+
if (this.state !== state) {
|
|
208
|
+
const prev = this.state;
|
|
209
|
+
this.state = state;
|
|
210
|
+
this._emit('stateChange', { current: state, previous: prev });
|
|
211
|
+
this._emit(state);
|
|
247
212
|
}
|
|
248
213
|
}
|
|
214
|
+
|
|
215
|
+
_emit(event, data) {
|
|
216
|
+
(this._listeners[event] || []).forEach(cb => cb(data));
|
|
217
|
+
}
|
|
249
218
|
}
|
|
250
219
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
220
|
+
// ========================================================================
|
|
221
|
+
// Channels Manager (like Ably.Channels)
|
|
222
|
+
// ========================================================================
|
|
223
|
+
|
|
224
|
+
class Channels {
|
|
225
|
+
constructor(realtime) {
|
|
226
|
+
this._realtime = realtime;
|
|
227
|
+
this._channels = {};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
get(name) {
|
|
231
|
+
if (!this._channels[name]) {
|
|
232
|
+
this._channels[name] = new Channel(this._realtime, name);
|
|
233
|
+
}
|
|
234
|
+
return this._channels[name];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
release(name) {
|
|
238
|
+
const ch = this._channels[name];
|
|
239
|
+
if (ch) {
|
|
240
|
+
ch.detach();
|
|
241
|
+
delete this._channels[name];
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
_reattachAll() {
|
|
246
|
+
Object.values(this._channels).forEach(ch => ch._reattach());
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ========================================================================
|
|
251
|
+
// Channel (like Ably.Channel)
|
|
252
|
+
// ========================================================================
|
|
253
|
+
|
|
254
|
+
class Channel {
|
|
255
|
+
constructor(realtime, name) {
|
|
256
|
+
this._realtime = realtime;
|
|
257
257
|
this.name = name;
|
|
258
|
-
this.
|
|
259
|
-
this.
|
|
260
|
-
this.
|
|
258
|
+
this.state = 'initialized';
|
|
259
|
+
this._listeners = {};
|
|
260
|
+
this._messageListeners = [];
|
|
261
|
+
this._attached = false;
|
|
261
262
|
|
|
262
263
|
// Presence sub-object
|
|
263
|
-
this.presence = new
|
|
264
|
+
this.presence = new Presence(this);
|
|
264
265
|
}
|
|
265
266
|
|
|
266
267
|
/**
|
|
267
|
-
*
|
|
268
|
+
* Attach to channel (start receiving messages)
|
|
268
269
|
*/
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
270
|
+
attach(callback) {
|
|
271
|
+
if (this._attached) {
|
|
272
|
+
callback?.();
|
|
273
|
+
return Promise.resolve();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
this._realtime._send({ action: 'attach', channel: this.name });
|
|
278
|
+
this._attached = true;
|
|
279
|
+
this.state = 'attaching';
|
|
280
|
+
|
|
281
|
+
// Wait for attached confirmation
|
|
282
|
+
const onAttached = (msg) => {
|
|
283
|
+
if (msg.action === 'attached') {
|
|
284
|
+
this.off('_internal', onAttached);
|
|
285
|
+
this.state = 'attached';
|
|
286
|
+
callback?.();
|
|
287
|
+
resolve();
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
this.on('_internal', onAttached);
|
|
291
|
+
|
|
292
|
+
// Timeout
|
|
293
|
+
setTimeout(() => {
|
|
294
|
+
if (this.state === 'attaching') {
|
|
295
|
+
this.state = 'attached'; // Assume success
|
|
296
|
+
callback?.();
|
|
297
|
+
resolve();
|
|
298
|
+
}
|
|
299
|
+
}, 1000);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Detach from channel
|
|
305
|
+
*/
|
|
306
|
+
detach(callback) {
|
|
307
|
+
this._realtime._send({ action: 'detach', channel: this.name });
|
|
308
|
+
this._attached = false;
|
|
309
|
+
this.state = 'detached';
|
|
310
|
+
this.presence._clear();
|
|
311
|
+
callback?.();
|
|
312
|
+
return Promise.resolve();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Subscribe to messages
|
|
317
|
+
* subscribe(callback) - all messages
|
|
318
|
+
* subscribe(event, callback) - specific event
|
|
319
|
+
*/
|
|
320
|
+
subscribe(eventOrCallback, callback) {
|
|
321
|
+
// Auto-attach
|
|
322
|
+
if (!this._attached) this.attach();
|
|
323
|
+
|
|
324
|
+
if (typeof eventOrCallback === 'function') {
|
|
325
|
+
// Subscribe to all messages
|
|
326
|
+
this._messageListeners.push(eventOrCallback);
|
|
327
|
+
return () => {
|
|
328
|
+
this._messageListeners = this._messageListeners.filter(cb => cb !== eventOrCallback);
|
|
329
|
+
};
|
|
274
330
|
} else {
|
|
275
331
|
// Subscribe to specific event
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
332
|
+
const event = eventOrCallback;
|
|
333
|
+
if (!this._listeners[event]) this._listeners[event] = [];
|
|
334
|
+
this._listeners[event].push(callback);
|
|
335
|
+
return () => {
|
|
336
|
+
this._listeners[event] = (this._listeners[event] || []).filter(cb => cb !== callback);
|
|
337
|
+
};
|
|
280
338
|
}
|
|
339
|
+
}
|
|
281
340
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Unsubscribe from messages
|
|
343
|
+
*/
|
|
344
|
+
unsubscribe(eventOrCallback, callback) {
|
|
345
|
+
if (typeof eventOrCallback === 'function') {
|
|
346
|
+
this._messageListeners = this._messageListeners.filter(cb => cb !== eventOrCallback);
|
|
347
|
+
} else if (eventOrCallback && callback) {
|
|
348
|
+
this._listeners[eventOrCallback] = (this._listeners[eventOrCallback] || []).filter(cb => cb !== callback);
|
|
349
|
+
} else if (eventOrCallback) {
|
|
350
|
+
delete this._listeners[eventOrCallback];
|
|
351
|
+
} else {
|
|
352
|
+
this._listeners = {};
|
|
353
|
+
this._messageListeners = [];
|
|
289
354
|
}
|
|
290
|
-
|
|
291
|
-
// Return unsubscribe function
|
|
292
|
-
return () => {
|
|
293
|
-
if (typeof event === 'function') {
|
|
294
|
-
this._allSubscribers = this._allSubscribers.filter(cb => cb !== callback);
|
|
295
|
-
} else {
|
|
296
|
-
this._subscribers[event] = (this._subscribers[event] || []).filter(cb => cb !== callback);
|
|
297
|
-
}
|
|
298
|
-
};
|
|
299
355
|
}
|
|
300
356
|
|
|
301
357
|
/**
|
|
302
|
-
* Publish
|
|
358
|
+
* Publish message to channel
|
|
359
|
+
* publish(name, data, callback)
|
|
360
|
+
* publish({ name, data }, callback)
|
|
303
361
|
*/
|
|
304
|
-
publish(
|
|
305
|
-
|
|
362
|
+
publish(nameOrMessage, dataOrCallback, callback) {
|
|
363
|
+
let name, data, cb;
|
|
364
|
+
|
|
365
|
+
if (typeof nameOrMessage === 'object') {
|
|
366
|
+
name = nameOrMessage.name;
|
|
367
|
+
data = nameOrMessage.data;
|
|
368
|
+
cb = dataOrCallback;
|
|
369
|
+
} else {
|
|
370
|
+
name = nameOrMessage;
|
|
371
|
+
data = dataOrCallback;
|
|
372
|
+
cb = callback;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this._realtime._send({
|
|
306
376
|
action: 'publish',
|
|
307
377
|
channel: this.name,
|
|
308
|
-
|
|
309
|
-
data
|
|
378
|
+
name,
|
|
379
|
+
data
|
|
310
380
|
});
|
|
381
|
+
|
|
382
|
+
cb?.();
|
|
383
|
+
return Promise.resolve();
|
|
311
384
|
}
|
|
312
385
|
|
|
313
386
|
/**
|
|
314
|
-
*
|
|
387
|
+
* Listen to internal events
|
|
315
388
|
*/
|
|
316
|
-
|
|
317
|
-
this.
|
|
318
|
-
this.
|
|
319
|
-
this.
|
|
320
|
-
|
|
321
|
-
this._pulse._send({
|
|
322
|
-
action: 'unsubscribe',
|
|
323
|
-
channel: this.name
|
|
324
|
-
});
|
|
389
|
+
on(event, callback) {
|
|
390
|
+
if (!this._listeners[event]) this._listeners[event] = [];
|
|
391
|
+
this._listeners[event].push(callback);
|
|
392
|
+
return () => this.off(event, callback);
|
|
325
393
|
}
|
|
326
394
|
|
|
327
|
-
|
|
395
|
+
off(event, callback) {
|
|
396
|
+
if (callback) {
|
|
397
|
+
this._listeners[event] = (this._listeners[event] || []).filter(cb => cb !== callback);
|
|
398
|
+
} else {
|
|
399
|
+
delete this._listeners[event];
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Internal
|
|
328
404
|
|
|
329
405
|
_handleMessage(msg) {
|
|
330
|
-
const
|
|
331
|
-
// Use msg itself as data if no data field (server sends {type: 'new_post', post: {...}})
|
|
332
|
-
const data = msg.data !== undefined ? msg.data : msg;
|
|
406
|
+
const { action, name, data, clientId, connectionId, timestamp } = msg;
|
|
333
407
|
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
this._subscribers[event].forEach(cb => cb(data, msg));
|
|
337
|
-
}
|
|
408
|
+
// Emit internal event
|
|
409
|
+
(this._listeners['_internal'] || []).forEach(cb => cb(msg));
|
|
338
410
|
|
|
339
|
-
|
|
340
|
-
|
|
411
|
+
if (action === 'message') {
|
|
412
|
+
// Message format (like Ably)
|
|
413
|
+
const message = { name, data, clientId, connectionId, timestamp };
|
|
341
414
|
|
|
342
|
-
|
|
343
|
-
|
|
415
|
+
// Call specific event listeners
|
|
416
|
+
if (name && this._listeners[name]) {
|
|
417
|
+
this._listeners[name].forEach(cb => cb(message));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Call all-message listeners
|
|
421
|
+
this._messageListeners.forEach(cb => cb(message));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Presence events
|
|
425
|
+
if (['enter', 'leave', 'update', 'presence.sync'].includes(action)) {
|
|
344
426
|
this.presence._handleMessage(msg);
|
|
345
427
|
}
|
|
346
428
|
}
|
|
347
429
|
|
|
348
|
-
|
|
349
|
-
if (this.
|
|
350
|
-
this.
|
|
351
|
-
|
|
352
|
-
channel: this.name
|
|
353
|
-
});
|
|
430
|
+
_reattach() {
|
|
431
|
+
if (this._attached) {
|
|
432
|
+
this._realtime._send({ action: 'attach', channel: this.name });
|
|
433
|
+
this.presence._reenter();
|
|
354
434
|
}
|
|
355
435
|
}
|
|
356
436
|
}
|
|
357
437
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
438
|
+
// ========================================================================
|
|
439
|
+
// Presence (like Ably.Presence)
|
|
440
|
+
// ========================================================================
|
|
441
|
+
|
|
442
|
+
class Presence {
|
|
362
443
|
constructor(channel) {
|
|
363
444
|
this._channel = channel;
|
|
364
|
-
this._members =
|
|
365
|
-
this.
|
|
366
|
-
this.
|
|
367
|
-
this.
|
|
368
|
-
this.
|
|
445
|
+
this._members = new Map(); // clientId -> member data
|
|
446
|
+
this._listeners = [];
|
|
447
|
+
this._enterListeners = [];
|
|
448
|
+
this._leaveListeners = [];
|
|
449
|
+
this._updateListeners = [];
|
|
369
450
|
this._myData = null;
|
|
451
|
+
this._entered = false;
|
|
370
452
|
}
|
|
371
453
|
|
|
372
454
|
/**
|
|
373
|
-
*
|
|
455
|
+
* Get current presence members
|
|
374
456
|
*/
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (this._members.length > 0) {
|
|
380
|
-
callback(this._members);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Request current presence from server
|
|
384
|
-
this._channel._pulse._send({
|
|
385
|
-
action: 'presence',
|
|
457
|
+
get(callback) {
|
|
458
|
+
// Request sync from server
|
|
459
|
+
this._channel._realtime._send({
|
|
460
|
+
action: 'presence.get',
|
|
386
461
|
channel: this._channel.name
|
|
387
462
|
});
|
|
388
463
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
464
|
+
// Return current members
|
|
465
|
+
const members = Array.from(this._members.values());
|
|
466
|
+
callback?.(null, members);
|
|
467
|
+
return Promise.resolve(members);
|
|
392
468
|
}
|
|
393
469
|
|
|
394
470
|
/**
|
|
395
|
-
* Subscribe to
|
|
471
|
+
* Subscribe to presence changes
|
|
472
|
+
* subscribe(callback) - all events
|
|
473
|
+
* subscribe(event, callback) - specific event (enter/leave/update)
|
|
396
474
|
*/
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
475
|
+
subscribe(eventOrCallback, callback) {
|
|
476
|
+
// Auto-attach channel
|
|
477
|
+
if (!this._channel._attached) this._channel.attach();
|
|
478
|
+
|
|
479
|
+
if (typeof eventOrCallback === 'function') {
|
|
480
|
+
this._listeners.push(eventOrCallback);
|
|
481
|
+
return () => {
|
|
482
|
+
this._listeners = this._listeners.filter(cb => cb !== eventOrCallback);
|
|
483
|
+
};
|
|
484
|
+
} else {
|
|
485
|
+
const event = eventOrCallback;
|
|
486
|
+
const list = event === 'enter' ? this._enterListeners :
|
|
487
|
+
event === 'leave' ? this._leaveListeners :
|
|
488
|
+
event === 'update' ? this._updateListeners : null;
|
|
489
|
+
if (list) {
|
|
490
|
+
list.push(callback);
|
|
491
|
+
return () => {
|
|
492
|
+
const idx = list.indexOf(callback);
|
|
493
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
402
497
|
}
|
|
403
498
|
|
|
404
499
|
/**
|
|
405
|
-
*
|
|
500
|
+
* Unsubscribe from presence
|
|
406
501
|
*/
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
502
|
+
unsubscribe(eventOrCallback, callback) {
|
|
503
|
+
if (typeof eventOrCallback === 'function') {
|
|
504
|
+
this._listeners = this._listeners.filter(cb => cb !== eventOrCallback);
|
|
505
|
+
} else if (eventOrCallback && callback) {
|
|
506
|
+
const list = eventOrCallback === 'enter' ? this._enterListeners :
|
|
507
|
+
eventOrCallback === 'leave' ? this._leaveListeners :
|
|
508
|
+
eventOrCallback === 'update' ? this._updateListeners : null;
|
|
509
|
+
if (list) {
|
|
510
|
+
const idx = list.indexOf(callback);
|
|
511
|
+
if (idx >= 0) list.splice(idx, 1);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
412
514
|
}
|
|
413
515
|
|
|
414
516
|
/**
|
|
415
|
-
* Enter presence
|
|
517
|
+
* Enter presence
|
|
416
518
|
*/
|
|
417
|
-
enter(data
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
519
|
+
enter(data, callback) {
|
|
520
|
+
if (typeof data === 'function') {
|
|
521
|
+
callback = data;
|
|
522
|
+
data = {};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
this._myData = data || {};
|
|
526
|
+
this._entered = true;
|
|
527
|
+
|
|
528
|
+
this._channel._realtime._send({
|
|
529
|
+
action: 'presence.enter',
|
|
421
530
|
channel: this._channel.name,
|
|
422
|
-
data:
|
|
531
|
+
data: this._myData,
|
|
532
|
+
clientId: this._channel._realtime.clientId
|
|
423
533
|
});
|
|
534
|
+
|
|
535
|
+
callback?.();
|
|
536
|
+
return Promise.resolve();
|
|
424
537
|
}
|
|
425
538
|
|
|
426
539
|
/**
|
|
427
540
|
* Leave presence
|
|
428
541
|
*/
|
|
429
|
-
leave() {
|
|
542
|
+
leave(data, callback) {
|
|
543
|
+
if (typeof data === 'function') {
|
|
544
|
+
callback = data;
|
|
545
|
+
data = null;
|
|
546
|
+
}
|
|
547
|
+
|
|
430
548
|
this._myData = null;
|
|
431
|
-
this.
|
|
432
|
-
|
|
549
|
+
this._entered = false;
|
|
550
|
+
|
|
551
|
+
this._channel._realtime._send({
|
|
552
|
+
action: 'presence.leave',
|
|
433
553
|
channel: this._channel.name
|
|
434
554
|
});
|
|
555
|
+
|
|
556
|
+
callback?.();
|
|
557
|
+
return Promise.resolve();
|
|
435
558
|
}
|
|
436
559
|
|
|
437
560
|
/**
|
|
438
561
|
* Update presence data
|
|
439
562
|
*/
|
|
440
|
-
update(data) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
563
|
+
update(data, callback) {
|
|
564
|
+
if (typeof data === 'function') {
|
|
565
|
+
callback = data;
|
|
566
|
+
data = {};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
this._myData = data || {};
|
|
570
|
+
|
|
571
|
+
this._channel._realtime._send({
|
|
572
|
+
action: 'presence.update',
|
|
444
573
|
channel: this._channel.name,
|
|
445
|
-
data:
|
|
574
|
+
data: this._myData
|
|
446
575
|
});
|
|
447
|
-
}
|
|
448
576
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
*/
|
|
452
|
-
get members() {
|
|
453
|
-
return this._members;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
/**
|
|
457
|
-
* Get member count (may be higher than members.length if list is limited)
|
|
458
|
-
*/
|
|
459
|
-
get count() {
|
|
460
|
-
return this._count || this._members.length;
|
|
577
|
+
callback?.();
|
|
578
|
+
return Promise.resolve();
|
|
461
579
|
}
|
|
462
580
|
|
|
463
|
-
// Internal
|
|
581
|
+
// Internal
|
|
464
582
|
|
|
465
583
|
_handleMessage(msg) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
this.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
this.
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
584
|
+
const { action, clientId, connectionId, data, timestamp, members } = msg;
|
|
585
|
+
|
|
586
|
+
if (action === 'presence.sync' && members) {
|
|
587
|
+
// Full sync
|
|
588
|
+
this._members.clear();
|
|
589
|
+
members.forEach(m => {
|
|
590
|
+
this._members.set(m.clientId || m.connectionId, m);
|
|
591
|
+
});
|
|
592
|
+
this._notifyListeners();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const member = { clientId, connectionId, data, timestamp, action };
|
|
597
|
+
|
|
598
|
+
if (action === 'enter') {
|
|
599
|
+
this._members.set(clientId || connectionId, member);
|
|
600
|
+
this._enterListeners.forEach(cb => cb(member));
|
|
601
|
+
} else if (action === 'leave') {
|
|
602
|
+
this._members.delete(clientId || connectionId);
|
|
603
|
+
this._leaveListeners.forEach(cb => cb(member));
|
|
604
|
+
} else if (action === 'update') {
|
|
605
|
+
this._members.set(clientId || connectionId, member);
|
|
606
|
+
this._updateListeners.forEach(cb => cb(member));
|
|
481
607
|
}
|
|
608
|
+
|
|
609
|
+
this._notifyListeners();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
_notifyListeners() {
|
|
613
|
+
const members = Array.from(this._members.values());
|
|
614
|
+
this._listeners.forEach(cb => cb(members));
|
|
482
615
|
}
|
|
483
616
|
|
|
484
|
-
|
|
485
|
-
this.
|
|
617
|
+
_clear() {
|
|
618
|
+
this._members.clear();
|
|
619
|
+
this._myData = null;
|
|
620
|
+
this._entered = false;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
_reenter() {
|
|
624
|
+
if (this._entered && this._myData !== null) {
|
|
625
|
+
this._channel._realtime._send({
|
|
626
|
+
action: 'presence.enter',
|
|
627
|
+
channel: this._channel.name,
|
|
628
|
+
data: this._myData,
|
|
629
|
+
clientId: this._channel._realtime.clientId
|
|
630
|
+
});
|
|
631
|
+
}
|
|
486
632
|
}
|
|
487
633
|
}
|
|
488
634
|
|
|
489
|
-
//
|
|
635
|
+
// ========================================================================
|
|
636
|
+
// Export
|
|
637
|
+
// ========================================================================
|
|
638
|
+
|
|
639
|
+
const Pulse = { Realtime };
|
|
640
|
+
|
|
641
|
+
// Also export Realtime directly for simpler usage
|
|
642
|
+
Pulse.Realtime.Realtime = Realtime;
|
|
643
|
+
|
|
490
644
|
if (typeof module !== 'undefined' && module.exports) {
|
|
491
645
|
module.exports = Pulse;
|
|
646
|
+
module.exports.Realtime = Realtime;
|
|
647
|
+
module.exports.default = Pulse;
|
|
492
648
|
} else if (typeof define === 'function' && define.amd) {
|
|
493
649
|
define([], function() { return Pulse; });
|
|
494
650
|
} else {
|
package/server.js
CHANGED
|
@@ -1,22 +1,140 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenKBS Pulse - Server SDK
|
|
2
|
+
* OpenKBS Pulse - Server SDK (Ably-compatible API)
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* import
|
|
5
|
+
* import Pulse from 'openkbs-pulse/server';
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* const rest = new Pulse.Rest({ kbId: 'xxx', apiKey: 'yyy' });
|
|
8
|
+
*
|
|
9
|
+
* // Publish to a channel
|
|
10
|
+
* await rest.channels.get('chat').publish('greeting', { text: 'Hello!' });
|
|
11
|
+
*
|
|
12
|
+
* // Get presence
|
|
13
|
+
* const members = await rest.channels.get('chat').presence.get();
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
const PULSE_API = process.env.PULSE_API_URL || 'https://kb.openkbs.com';
|
|
12
17
|
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Rest Client (like Ably.Rest)
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
class Rest {
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.kbId = options.kbId || process.env.OPENKBS_KB_ID;
|
|
25
|
+
this.apiKey = options.apiKey || process.env.OPENKBS_API_KEY;
|
|
26
|
+
this.apiUrl = options.apiUrl || PULSE_API;
|
|
27
|
+
|
|
28
|
+
if (!this.kbId || !this.apiKey) {
|
|
29
|
+
console.warn('[Pulse] kbId and apiKey required for server SDK');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Channels manager
|
|
33
|
+
this.channels = new RestChannels(this);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async _request(action, params = {}) {
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(this.apiUrl, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
action,
|
|
43
|
+
kbId: this.kbId,
|
|
44
|
+
apiKey: this.apiKey,
|
|
45
|
+
...params
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
return await res.json();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error('[Pulse] Request error:', err.message);
|
|
51
|
+
return { error: err.message };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================================
|
|
57
|
+
// RestChannels (like Ably.Rest.Channels)
|
|
58
|
+
// ============================================================================
|
|
59
|
+
|
|
60
|
+
class RestChannels {
|
|
61
|
+
constructor(rest) {
|
|
62
|
+
this._rest = rest;
|
|
63
|
+
this._channels = {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get(name) {
|
|
67
|
+
if (!this._channels[name]) {
|
|
68
|
+
this._channels[name] = new RestChannel(this._rest, name);
|
|
69
|
+
}
|
|
70
|
+
return this._channels[name];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// RestChannel (like Ably.Rest.Channel)
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
class RestChannel {
|
|
79
|
+
constructor(rest, name) {
|
|
80
|
+
this._rest = rest;
|
|
81
|
+
this.name = name;
|
|
82
|
+
this.presence = new RestPresence(this);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Publish message to channel
|
|
87
|
+
* publish(name, data) - publish with event name
|
|
88
|
+
* publish({ name, data }) - publish message object
|
|
89
|
+
*/
|
|
90
|
+
async publish(nameOrMessage, data) {
|
|
91
|
+
let name, messageData;
|
|
92
|
+
|
|
93
|
+
if (typeof nameOrMessage === 'object') {
|
|
94
|
+
name = nameOrMessage.name;
|
|
95
|
+
messageData = nameOrMessage.data;
|
|
96
|
+
} else {
|
|
97
|
+
name = nameOrMessage;
|
|
98
|
+
messageData = data;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const result = await this._rest._request('pulsePublish', {
|
|
102
|
+
channel: this.name,
|
|
103
|
+
message: { type: name, ...messageData }
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// RestPresence (like Ably.Rest.Presence)
|
|
112
|
+
// ============================================================================
|
|
113
|
+
|
|
114
|
+
class RestPresence {
|
|
115
|
+
constructor(channel) {
|
|
116
|
+
this._channel = channel;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get current presence members
|
|
121
|
+
*/
|
|
122
|
+
async get() {
|
|
123
|
+
const result = await this._channel._rest._request('pulsePresence', {
|
|
124
|
+
channel: this._channel.name
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return result.members || [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ============================================================================
|
|
132
|
+
// Simple API (backwards compatible)
|
|
133
|
+
// ============================================================================
|
|
134
|
+
|
|
13
135
|
const pulse = {
|
|
14
136
|
/**
|
|
15
|
-
* Get a Pulse token for
|
|
16
|
-
* @param {string} kbId - KB ID (optional, uses env var)
|
|
17
|
-
* @param {string} apiKey - API Key (optional, uses env var)
|
|
18
|
-
* @param {string} userId - User identifier (default: 'anonymous')
|
|
19
|
-
* @returns {Promise<{token, endpoint, region, expiresIn}>}
|
|
137
|
+
* Get a Pulse token for client authentication
|
|
20
138
|
*/
|
|
21
139
|
async getToken(kbId, apiKey, userId = 'anonymous') {
|
|
22
140
|
const kb = kbId || process.env.OPENKBS_KB_ID;
|
|
@@ -36,22 +154,22 @@ const pulse = {
|
|
|
36
154
|
userId
|
|
37
155
|
})
|
|
38
156
|
});
|
|
157
|
+
|
|
39
158
|
return res.json();
|
|
40
159
|
},
|
|
41
160
|
|
|
42
161
|
/**
|
|
43
162
|
* Publish a message to a channel
|
|
44
163
|
* @param {string} channel - Channel name
|
|
45
|
-
* @param {string}
|
|
164
|
+
* @param {string} name - Event name
|
|
46
165
|
* @param {object} data - Event data
|
|
47
|
-
* @param {object} options - Optional { kbId, apiKey } overrides
|
|
48
166
|
*/
|
|
49
|
-
async publish(channel,
|
|
167
|
+
async publish(channel, name, data, options = {}) {
|
|
50
168
|
const kbId = options.kbId || process.env.OPENKBS_KB_ID;
|
|
51
169
|
const apiKey = options.apiKey || process.env.OPENKBS_API_KEY;
|
|
52
170
|
|
|
53
171
|
if (!kbId || !apiKey) {
|
|
54
|
-
console.
|
|
172
|
+
console.warn('[Pulse] Not configured, skipping publish');
|
|
55
173
|
return { success: false, error: 'not_configured' };
|
|
56
174
|
}
|
|
57
175
|
|
|
@@ -64,21 +182,19 @@ const pulse = {
|
|
|
64
182
|
kbId,
|
|
65
183
|
apiKey,
|
|
66
184
|
channel,
|
|
67
|
-
message: { type:
|
|
185
|
+
message: { type: name, ...data }
|
|
68
186
|
})
|
|
69
187
|
});
|
|
70
188
|
const result = await res.json();
|
|
71
189
|
return { success: true, ...result };
|
|
72
|
-
} catch (
|
|
73
|
-
console.error('[Pulse] Publish error:',
|
|
74
|
-
return { success: false, error:
|
|
190
|
+
} catch (err) {
|
|
191
|
+
console.error('[Pulse] Publish error:', err.message);
|
|
192
|
+
return { success: false, error: err.message };
|
|
75
193
|
}
|
|
76
194
|
},
|
|
77
195
|
|
|
78
196
|
/**
|
|
79
|
-
* Get presence
|
|
80
|
-
* @param {string} channel - Channel name
|
|
81
|
-
* @param {object} options - Optional { kbId, apiKey } overrides
|
|
197
|
+
* Get presence for a channel
|
|
82
198
|
*/
|
|
83
199
|
async presence(channel, options = {}) {
|
|
84
200
|
const kbId = options.kbId || process.env.OPENKBS_KB_ID;
|
|
@@ -100,11 +216,18 @@ const pulse = {
|
|
|
100
216
|
})
|
|
101
217
|
});
|
|
102
218
|
return await res.json();
|
|
103
|
-
} catch (
|
|
104
|
-
console.error('[Pulse] Presence error:',
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error('[Pulse] Presence error:', err.message);
|
|
105
221
|
return { count: 0, members: [] };
|
|
106
222
|
}
|
|
107
223
|
}
|
|
108
224
|
};
|
|
109
225
|
|
|
110
|
-
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Exports
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
const Pulse = { Rest };
|
|
231
|
+
|
|
232
|
+
export default pulse; // Default export: simple API
|
|
233
|
+
export { Rest, pulse, Pulse }; // Named exports
|