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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/pulse.js +469 -313
  3. package/server.js +146 -23
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openkbs-pulse",
3
- "version": "1.0.17",
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 v1.0.12 - Real-time WebSocket SDK
2
+ * OpenKBS Pulse - Client SDK (Ably-compatible API)
3
3
  *
4
4
  * Usage:
5
- * const pulse = new Pulse({ kbId: 'your-kb-id', token: 'user-token' });
5
+ * const realtime = new Pulse.Realtime({ kbId: 'xxx', token: 'yyy' });
6
6
  *
7
- * const channel = pulse.channel('posts');
8
- * channel.subscribe('new_post', (data) => console.log('New post:', data));
9
- * channel.publish('new_post', { title: 'Hello' });
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('Online:', members.length));
12
- * channel.presence.enter({ name: 'John' });
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 PULSE_ENDPOINTS = {
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
- * Main Pulse client
28
- */
29
- class Pulse {
25
+ // ========================================================================
26
+ // Realtime Client (like Ably.Realtime)
27
+ // ========================================================================
28
+
29
+ class Realtime {
30
30
  constructor(options = {}) {
31
- if (!options.kbId) {
32
- throw new Error('Pulse: kbId is required');
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._defaultChannel = options.channel || 'default';
41
- this.region = options.region || DEFAULT_REGION;
42
- this.endpoint = options.endpoint || PULSE_ENDPOINTS[this.region] || PULSE_ENDPOINTS[DEFAULT_REGION];
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
- this._channels = {};
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 unless disabled
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 && this._ws.readyState === WebSocket.OPEN) {
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._setConnectionState('connecting');
63
+ this.connection._setState('connecting');
70
64
 
71
- const url = `${this.endpoint}?kbId=${this.kbId}&token=${this.token}&channel=${this._defaultChannel}`;
72
- this._log('Connecting to:', url);
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._setupWebSocket();
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._setConnectionState('failed');
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._setConnectionState('disconnecting');
90
-
91
- if (this._ws) {
92
- this._ws.close(1000, 'Client disconnect');
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
- _setupWebSocket() {
138
- this._ws.onopen = () => {
139
- this._log('Connected');
140
- this._reconnectAttempt = 0;
141
- this._setConnectionState('connected');
91
+ _onOpen() {
92
+ this._log('Connected');
93
+ this._reconnectAttempt = 0;
94
+ this.connection._setState('connected');
142
95
 
143
- // Resubscribe to all channels
144
- Object.values(this._channels).forEach(channel => {
145
- channel._resubscribe();
146
- });
96
+ // Reattach all channels
97
+ this.channels._reattachAll();
147
98
 
148
- // Flush message queue
149
- this._flushMessageQueue();
150
- };
99
+ // Flush queued messages
100
+ this._flushQueue();
101
+ }
151
102
 
152
- this._ws.onclose = (e) => {
153
- this._log('Disconnected:', e.code, e.reason);
154
- this._setConnectionState('disconnected');
103
+ _onClose(e) {
104
+ this._log('Disconnected:', e.code, e.reason);
105
+ this.connection._setState('disconnected');
155
106
 
156
- if (!this._intentionalClose) {
157
- this._scheduleReconnect();
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
- this._ws.onmessage = (e) => {
167
- this._handleMessage(e.data);
168
- };
112
+ _onError(err) {
113
+ this._log('Error:', err);
114
+ this.connection._setState('failed');
169
115
  }
170
116
 
171
- _handleMessage(data) {
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 && this._ws.readyState === WebSocket.OPEN) {
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
- _flushMessageQueue() {
152
+ _flushQueue() {
217
153
  while (this._messageQueue.length > 0) {
218
- const msg = this._messageQueue.shift();
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._setConnectionState('reconnecting');
162
+ this.connection._setState('connecting');
163
+ setTimeout(() => !this._intentionalClose && this.connect(), delay);
164
+ }
229
165
 
230
- setTimeout(() => {
231
- if (!this._intentionalClose) {
232
- this.connect();
233
- }
234
- }, delay);
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
- _setConnectionState(state) {
238
- if (this._connectionState !== state) {
239
- this._connectionState = state;
240
- this._stateListeners.forEach(cb => cb(state));
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
- _log(...args) {
245
- if (this.debug) {
246
- console.log('[Pulse]', ...args);
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
- * Channel for pub/sub messaging
253
- */
254
- class PulseChannel {
255
- constructor(pulse, name) {
256
- this._pulse = pulse;
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._subscribers = {};
259
- this._allSubscribers = [];
260
- this._subscribed = false;
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 PulsePresence(this);
264
+ this.presence = new Presence(this);
264
265
  }
265
266
 
266
267
  /**
267
- * Subscribe to a specific event type
268
+ * Attach to channel (start receiving messages)
268
269
  */
269
- subscribe(event, callback) {
270
- if (typeof event === 'function') {
271
- // Subscribe to all events
272
- callback = event;
273
- this._allSubscribers.push(callback);
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
- if (!this._subscribers[event]) {
277
- this._subscribers[event] = [];
278
- }
279
- this._subscribers[event].push(callback);
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
- // Send subscribe message to server
283
- if (!this._subscribed) {
284
- this._pulse._send({
285
- action: 'subscribe',
286
- channel: this.name
287
- });
288
- this._subscribed = true;
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 a message to the channel
358
+ * Publish message to channel
359
+ * publish(name, data, callback)
360
+ * publish({ name, data }, callback)
303
361
  */
304
- publish(event, data) {
305
- return this._pulse._send({
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
- event: event,
309
- data: data
378
+ name,
379
+ data
310
380
  });
381
+
382
+ cb?.();
383
+ return Promise.resolve();
311
384
  }
312
385
 
313
386
  /**
314
- * Unsubscribe from channel
387
+ * Listen to internal events
315
388
  */
316
- unsubscribe() {
317
- this._subscribers = {};
318
- this._allSubscribers = [];
319
- this._subscribed = false;
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
- // Internal methods
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 event = msg.event || msg.type;
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
- // Call specific event subscribers
335
- if (event && this._subscribers[event]) {
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
- // Call all-event subscribers
340
- this._allSubscribers.forEach(cb => cb(data, msg));
411
+ if (action === 'message') {
412
+ // Message format (like Ably)
413
+ const message = { name, data, clientId, connectionId, timestamp };
341
414
 
342
- // Handle presence events
343
- if (msg.type === 'presence') {
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
- _resubscribe() {
349
- if (this._subscribed) {
350
- this._pulse._send({
351
- action: 'subscribe',
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
- * Presence tracking for a channel
360
- */
361
- class PulsePresence {
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._count = 0;
366
- this._subscribers = [];
367
- this._enterSubscribers = [];
368
- this._leaveSubscribers = [];
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
- * Subscribe to presence changes
455
+ * Get current presence members
374
456
  */
375
- subscribe(callback) {
376
- this._subscribers.push(callback);
377
-
378
- // Return current members immediately if we have them
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
- return () => {
390
- this._subscribers = this._subscribers.filter(cb => cb !== callback);
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 member enter events
471
+ * Subscribe to presence changes
472
+ * subscribe(callback) - all events
473
+ * subscribe(event, callback) - specific event (enter/leave/update)
396
474
  */
397
- onEnter(callback) {
398
- this._enterSubscribers.push(callback);
399
- return () => {
400
- this._enterSubscribers = this._enterSubscribers.filter(cb => cb !== callback);
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
- * Subscribe to member leave events
500
+ * Unsubscribe from presence
406
501
  */
407
- onLeave(callback) {
408
- this._leaveSubscribers.push(callback);
409
- return () => {
410
- this._leaveSubscribers = this._leaveSubscribers.filter(cb => cb !== callback);
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 with optional data
517
+ * Enter presence
416
518
  */
417
- enter(data = {}) {
418
- this._myData = data;
419
- this._channel._pulse._send({
420
- action: 'presence_enter',
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: 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._channel._pulse._send({
432
- action: 'presence_leave',
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
- this._myData = data;
442
- this._channel._pulse._send({
443
- action: 'presence_update',
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: data
574
+ data: this._myData
446
575
  });
447
- }
448
576
 
449
- /**
450
- * Get current members
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 methods
581
+ // Internal
464
582
 
465
583
  _handleMessage(msg) {
466
- // Handle sync (full member list) - can come from action='sync' or just type='presence'
467
- if (msg.action === 'sync' || (msg.type === 'presence' && msg.members)) {
468
- this._members = msg.members || [];
469
- this._count = msg.count !== undefined ? msg.count : this._members.length;
470
- this._notifySubscribers();
471
- } else if (msg.action === 'enter') {
472
- this._members.push(msg.member);
473
- this._count = (this._count || 0) + 1;
474
- this._notifySubscribers();
475
- this._enterSubscribers.forEach(cb => cb(msg.member));
476
- } else if (msg.action === 'leave') {
477
- this._members = this._members.filter(m => m.connectionId !== msg.member.connectionId);
478
- this._count = Math.max(0, (this._count || 1) - 1);
479
- this._notifySubscribers();
480
- this._leaveSubscribers.forEach(cb => cb(msg.member));
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
- _notifySubscribers() {
485
- this._subscribers.forEach(cb => cb(this._members));
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
- // Export for different module systems
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 pulse from 'openkbs-pulse/server';
5
+ * import Pulse from 'openkbs-pulse/server';
6
6
  *
7
- * await pulse.publish('posts', 'new_post', { post });
8
- * const { count, members } = await pulse.presence('posts');
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 WebSocket authentication
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} event - Event type
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, event, data, options = {}) {
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.log('[Pulse] Not configured, skipping publish');
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: event, ...data }
185
+ message: { type: name, ...data }
68
186
  })
69
187
  });
70
188
  const result = await res.json();
71
189
  return { success: true, ...result };
72
- } catch (e) {
73
- console.error('[Pulse] Publish error:', e.message);
74
- return { success: false, error: e.message };
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 info for a channel
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 (e) {
104
- console.error('[Pulse] Presence error:', e.message);
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
- export default pulse;
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