@nolag/notify 0.1.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 +166 -0
- package/dist/BadgeManager.d.ts +23 -0
- package/dist/EventEmitter.d.ts +13 -0
- package/dist/NoLagNotify.d.ts +77 -0
- package/dist/NotificationStore.d.ts +48 -0
- package/dist/NotifyChannel.d.ts +60 -0
- package/dist/PresenceManager.d.ts +40 -0
- package/dist/browser.d.ts +8 -0
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -0
- package/dist/constants.d.ts +10 -0
- package/dist/index.cjs +767 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.mjs +763 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types.d.ts +87 -0
- package/dist/utils.d.ts +2 -0
- package/package.json +57 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jsSdk = require('@nolag/js-sdk');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
|
|
7
|
+
*/
|
|
8
|
+
class EventEmitter {
|
|
9
|
+
constructor() {
|
|
10
|
+
this._handlers = new Map();
|
|
11
|
+
}
|
|
12
|
+
on(event, handler) {
|
|
13
|
+
if (!this._handlers.has(event)) {
|
|
14
|
+
this._handlers.set(event, new Set());
|
|
15
|
+
}
|
|
16
|
+
this._handlers.get(event).add(handler);
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
off(event, handler) {
|
|
20
|
+
if (handler) {
|
|
21
|
+
this._handlers.get(event)?.delete(handler);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
this._handlers.delete(event);
|
|
25
|
+
}
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
removeAllListeners() {
|
|
29
|
+
this._handlers.clear();
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
emit(event, ...args) {
|
|
33
|
+
const handlers = this._handlers.get(event);
|
|
34
|
+
if (!handlers)
|
|
35
|
+
return;
|
|
36
|
+
for (const handler of handlers) {
|
|
37
|
+
try {
|
|
38
|
+
handler(...args);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
console.error(`Error in ${String(event)} handler:`, e);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
listenerCount(event) {
|
|
46
|
+
return this._handlers.get(event)?.size ?? 0;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bounded, deduplicated notification cache ordered by timestamp,
|
|
52
|
+
* with read/unread tracking.
|
|
53
|
+
*/
|
|
54
|
+
class NotificationStore {
|
|
55
|
+
constructor(maxSize) {
|
|
56
|
+
this._notifications = [];
|
|
57
|
+
this._ids = new Set();
|
|
58
|
+
this._maxSize = maxSize;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Add a notification. Returns true if the notification was new (not a duplicate).
|
|
62
|
+
*/
|
|
63
|
+
add(notification) {
|
|
64
|
+
if (this._ids.has(notification.id)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
this._ids.add(notification.id);
|
|
68
|
+
this._notifications.push(notification);
|
|
69
|
+
// Keep sorted by timestamp
|
|
70
|
+
if (this._notifications.length > 1 &&
|
|
71
|
+
notification.timestamp < this._notifications[this._notifications.length - 2].timestamp) {
|
|
72
|
+
this._notifications.sort((a, b) => a.timestamp - b.timestamp);
|
|
73
|
+
}
|
|
74
|
+
// Trim if over capacity
|
|
75
|
+
while (this._notifications.length > this._maxSize) {
|
|
76
|
+
const removed = this._notifications.shift();
|
|
77
|
+
this._ids.delete(removed.id);
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Mark a notification as read by id.
|
|
83
|
+
* Returns true if the notification was found.
|
|
84
|
+
*/
|
|
85
|
+
markRead(id) {
|
|
86
|
+
const notification = this._notifications.find((n) => n.id === id);
|
|
87
|
+
if (!notification)
|
|
88
|
+
return false;
|
|
89
|
+
notification.read = true;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Mark all notifications as read.
|
|
94
|
+
*/
|
|
95
|
+
markAllRead() {
|
|
96
|
+
for (const notification of this._notifications) {
|
|
97
|
+
notification.read = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Get all notifications in timestamp order.
|
|
102
|
+
*/
|
|
103
|
+
getAll() {
|
|
104
|
+
return [...this._notifications];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Get all unread notifications.
|
|
108
|
+
*/
|
|
109
|
+
getUnread() {
|
|
110
|
+
return this._notifications.filter((n) => !n.read);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get the number of unread notifications.
|
|
114
|
+
*/
|
|
115
|
+
get unreadCount() {
|
|
116
|
+
return this._notifications.filter((n) => !n.read).length;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get notification count.
|
|
120
|
+
*/
|
|
121
|
+
get size() {
|
|
122
|
+
return this._notifications.length;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Check if a notification ID exists.
|
|
126
|
+
*/
|
|
127
|
+
has(id) {
|
|
128
|
+
return this._ids.has(id);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Clear all notifications.
|
|
132
|
+
*/
|
|
133
|
+
clear() {
|
|
134
|
+
this._notifications = [];
|
|
135
|
+
this._ids.clear();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function generateId() {
|
|
140
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
141
|
+
return crypto.randomUUID();
|
|
142
|
+
}
|
|
143
|
+
return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
|
|
144
|
+
}
|
|
145
|
+
function createLogger(prefix, enabled) {
|
|
146
|
+
if (!enabled) {
|
|
147
|
+
return (..._args) => { };
|
|
148
|
+
}
|
|
149
|
+
return (...args) => {
|
|
150
|
+
console.log(`[${prefix}]`, ...args);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Default app name for channel topic prefixes */
|
|
155
|
+
const DEFAULT_APP_NAME = 'notify';
|
|
156
|
+
/** Default max notifications kept per channel */
|
|
157
|
+
const DEFAULT_MAX_NOTIFICATION_CACHE = 500;
|
|
158
|
+
/** Topic name for notifications within a channel */
|
|
159
|
+
const TOPIC_NOTIFICATIONS = 'notifications';
|
|
160
|
+
/** Topic name for read receipts within a channel */
|
|
161
|
+
const TOPIC_READ = '_read';
|
|
162
|
+
/** Lobby ID for global online presence */
|
|
163
|
+
const LOBBY_ID = 'online';
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* NotifyChannel — a single notification channel with read/unread tracking.
|
|
167
|
+
*
|
|
168
|
+
* Created via `NoLagNotify.subscribe(name)`. Do not instantiate directly.
|
|
169
|
+
*/
|
|
170
|
+
class NotifyChannel extends EventEmitter {
|
|
171
|
+
/** @internal */
|
|
172
|
+
constructor(name, roomContext, options, log) {
|
|
173
|
+
super();
|
|
174
|
+
this._active = false;
|
|
175
|
+
this.name = name;
|
|
176
|
+
this._roomContext = roomContext;
|
|
177
|
+
this._options = options;
|
|
178
|
+
this._store = new NotificationStore(options.maxNotificationCache);
|
|
179
|
+
this._log = log;
|
|
180
|
+
}
|
|
181
|
+
// ============ Public Properties ============
|
|
182
|
+
/** All notifications in this channel (timestamp order) */
|
|
183
|
+
get notifications() {
|
|
184
|
+
return this._store.getAll();
|
|
185
|
+
}
|
|
186
|
+
/** Number of unread notifications */
|
|
187
|
+
get unreadCount() {
|
|
188
|
+
return this._store.unreadCount;
|
|
189
|
+
}
|
|
190
|
+
/** Whether this channel is currently active */
|
|
191
|
+
get active() {
|
|
192
|
+
return this._active;
|
|
193
|
+
}
|
|
194
|
+
// ============ Sending ============
|
|
195
|
+
/**
|
|
196
|
+
* Send a notification to this channel.
|
|
197
|
+
*/
|
|
198
|
+
send(title, opts) {
|
|
199
|
+
const notification = {
|
|
200
|
+
id: generateId(),
|
|
201
|
+
channel: this.name,
|
|
202
|
+
title,
|
|
203
|
+
body: opts?.body,
|
|
204
|
+
icon: opts?.icon,
|
|
205
|
+
data: opts?.data,
|
|
206
|
+
timestamp: Date.now()};
|
|
207
|
+
this._roomContext.emit(TOPIC_NOTIFICATIONS, {
|
|
208
|
+
id: notification.id,
|
|
209
|
+
channel: notification.channel,
|
|
210
|
+
title: notification.title,
|
|
211
|
+
body: notification.body,
|
|
212
|
+
icon: notification.icon,
|
|
213
|
+
data: notification.data,
|
|
214
|
+
timestamp: notification.timestamp,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
// ============ Read Tracking ============
|
|
218
|
+
/**
|
|
219
|
+
* Mark a single notification as read by id.
|
|
220
|
+
* Emits the read receipt to the _read topic for cross-tab sync.
|
|
221
|
+
*/
|
|
222
|
+
markRead(id) {
|
|
223
|
+
if (this._store.markRead(id)) {
|
|
224
|
+
this._log('Mark read:', id);
|
|
225
|
+
this._roomContext.emit(TOPIC_READ, { id, channel: this.name });
|
|
226
|
+
this.emit('read', id);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Mark all notifications in this channel as read.
|
|
231
|
+
*/
|
|
232
|
+
markAllRead() {
|
|
233
|
+
this._store.markAllRead();
|
|
234
|
+
this._log('Mark all read:', this.name);
|
|
235
|
+
this._roomContext.emit(TOPIC_READ, { all: true, channel: this.name });
|
|
236
|
+
this.emit('readAll');
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Get all notifications (alias for the notifications getter).
|
|
240
|
+
*/
|
|
241
|
+
getNotifications() {
|
|
242
|
+
return this._store.getAll();
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Get all unread notifications.
|
|
246
|
+
*/
|
|
247
|
+
getUnread() {
|
|
248
|
+
return this._store.getUnread();
|
|
249
|
+
}
|
|
250
|
+
// ============ Internal (called by NoLagNotify) ============
|
|
251
|
+
/** @internal Subscribe to notifications and _read topics */
|
|
252
|
+
_subscribe() {
|
|
253
|
+
this._log('Channel subscribe:', this.name);
|
|
254
|
+
this._roomContext.subscribe(TOPIC_NOTIFICATIONS);
|
|
255
|
+
this._roomContext.subscribe(TOPIC_READ);
|
|
256
|
+
this._roomContext.on(TOPIC_NOTIFICATIONS, (data, meta) => {
|
|
257
|
+
this._handleIncomingNotification(data, meta);
|
|
258
|
+
});
|
|
259
|
+
this._roomContext.on(TOPIC_READ, (data) => {
|
|
260
|
+
this._handleIncomingRead(data);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** @internal Activate this channel (mark as visible/active) */
|
|
264
|
+
_activate() {
|
|
265
|
+
this._log('Channel activate:', this.name);
|
|
266
|
+
this._active = true;
|
|
267
|
+
}
|
|
268
|
+
/** @internal Deactivate this channel */
|
|
269
|
+
_deactivate() {
|
|
270
|
+
this._log('Channel deactivate:', this.name);
|
|
271
|
+
this._active = false;
|
|
272
|
+
}
|
|
273
|
+
/** @internal Handle replay start event */
|
|
274
|
+
_handleReplayStart(count) {
|
|
275
|
+
this.emit('replayStart', { count });
|
|
276
|
+
}
|
|
277
|
+
/** @internal Handle replay end event */
|
|
278
|
+
_handleReplayEnd(replayed) {
|
|
279
|
+
this.emit('replayEnd', { replayed });
|
|
280
|
+
}
|
|
281
|
+
/** @internal Unsubscribe and clean up */
|
|
282
|
+
_cleanup() {
|
|
283
|
+
this._log('Channel cleanup:', this.name);
|
|
284
|
+
this._roomContext.unsubscribe(TOPIC_NOTIFICATIONS);
|
|
285
|
+
this._roomContext.unsubscribe(TOPIC_READ);
|
|
286
|
+
this._roomContext.off(TOPIC_NOTIFICATIONS);
|
|
287
|
+
this._roomContext.off(TOPIC_READ);
|
|
288
|
+
this._store.clear();
|
|
289
|
+
this.removeAllListeners();
|
|
290
|
+
}
|
|
291
|
+
// ============ Private ============
|
|
292
|
+
_handleIncomingNotification(data, meta) {
|
|
293
|
+
const raw = data;
|
|
294
|
+
const notification = {
|
|
295
|
+
id: raw.id || generateId(),
|
|
296
|
+
channel: this.name,
|
|
297
|
+
title: raw.title,
|
|
298
|
+
body: raw.body,
|
|
299
|
+
icon: raw.icon,
|
|
300
|
+
data: raw.data,
|
|
301
|
+
timestamp: raw.timestamp || Date.now(),
|
|
302
|
+
read: false,
|
|
303
|
+
isReplay: meta.isReplay ?? false,
|
|
304
|
+
};
|
|
305
|
+
if (this._store.add(notification)) {
|
|
306
|
+
this._log('Notification received:', notification.id, notification.title);
|
|
307
|
+
this.emit('notification', notification);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
_handleIncomingRead(data) {
|
|
311
|
+
const raw = data;
|
|
312
|
+
if (raw.all === true) {
|
|
313
|
+
this._store.markAllRead();
|
|
314
|
+
this.emit('readAll');
|
|
315
|
+
}
|
|
316
|
+
else if (typeof raw.id === 'string') {
|
|
317
|
+
if (this._store.markRead(raw.id)) {
|
|
318
|
+
this.emit('read', raw.id);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Aggregates unread notification counts across channels.
|
|
326
|
+
*/
|
|
327
|
+
class BadgeManager {
|
|
328
|
+
constructor() {
|
|
329
|
+
this._counts = new Map();
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Update the unread count for a channel.
|
|
333
|
+
*/
|
|
334
|
+
update(channel, unreadCount) {
|
|
335
|
+
this._counts.set(channel, unreadCount);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Get the unread count for a specific channel.
|
|
339
|
+
*/
|
|
340
|
+
get(channel) {
|
|
341
|
+
return this._counts.get(channel) ?? 0;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Get all badge counts — total and per-channel breakdown.
|
|
345
|
+
*/
|
|
346
|
+
getAll() {
|
|
347
|
+
const byChannel = {};
|
|
348
|
+
let total = 0;
|
|
349
|
+
for (const [channel, count] of this._counts) {
|
|
350
|
+
byChannel[channel] = count;
|
|
351
|
+
total += count;
|
|
352
|
+
}
|
|
353
|
+
return { total, byChannel };
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Clear all counts.
|
|
357
|
+
*/
|
|
358
|
+
clear() {
|
|
359
|
+
this._counts.clear();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Maps actorTokenId to NotifyUser for global presence tracking.
|
|
365
|
+
*/
|
|
366
|
+
class PresenceManager {
|
|
367
|
+
constructor() {
|
|
368
|
+
this._users = new Map();
|
|
369
|
+
this._actorToUserId = new Map();
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Add or update a user from presence data.
|
|
373
|
+
* Returns the NotifyUser, or null if presence data is invalid.
|
|
374
|
+
*/
|
|
375
|
+
addFromPresence(actorTokenId, presenceData, joinedAt) {
|
|
376
|
+
if (!presenceData?.userId)
|
|
377
|
+
return null;
|
|
378
|
+
const existing = this._actorToUserId.get(actorTokenId);
|
|
379
|
+
const userId = presenceData.userId || existing || actorTokenId;
|
|
380
|
+
const user = {
|
|
381
|
+
userId,
|
|
382
|
+
actorTokenId,
|
|
383
|
+
metadata: presenceData.metadata,
|
|
384
|
+
joinedAt: joinedAt || Date.now(),
|
|
385
|
+
};
|
|
386
|
+
this._users.set(userId, user);
|
|
387
|
+
this._actorToUserId.set(actorTokenId, userId);
|
|
388
|
+
return user;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Remove a user by actorTokenId.
|
|
392
|
+
* Returns the removed user, or null if not found.
|
|
393
|
+
*/
|
|
394
|
+
removeByActorId(actorTokenId) {
|
|
395
|
+
const userId = this._actorToUserId.get(actorTokenId);
|
|
396
|
+
if (!userId)
|
|
397
|
+
return null;
|
|
398
|
+
const user = this._users.get(userId) || null;
|
|
399
|
+
this._users.delete(userId);
|
|
400
|
+
this._actorToUserId.delete(actorTokenId);
|
|
401
|
+
return user;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Get a user by userId.
|
|
405
|
+
*/
|
|
406
|
+
getUser(userId) {
|
|
407
|
+
return this._users.get(userId);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Get a user by actorTokenId.
|
|
411
|
+
*/
|
|
412
|
+
getUserByActorId(actorTokenId) {
|
|
413
|
+
const userId = this._actorToUserId.get(actorTokenId);
|
|
414
|
+
return userId ? this._users.get(userId) : undefined;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Get all tracked users.
|
|
418
|
+
*/
|
|
419
|
+
getAll() {
|
|
420
|
+
return Array.from(this._users.values());
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Clear all tracked users.
|
|
424
|
+
*/
|
|
425
|
+
clear() {
|
|
426
|
+
this._users.clear();
|
|
427
|
+
this._actorToUserId.clear();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* NoLagNotify — high-level notifications SDK built on @nolag/js-sdk.
|
|
433
|
+
*
|
|
434
|
+
* Provides multi-channel notifications, read/unread tracking, badge counts,
|
|
435
|
+
* message replay, and global presence — all framework-agnostic via events.
|
|
436
|
+
*
|
|
437
|
+
* @example
|
|
438
|
+
* ```typescript
|
|
439
|
+
* import { NoLagNotify } from '@nolag/notify';
|
|
440
|
+
*
|
|
441
|
+
* const notify = new NoLagNotify(token);
|
|
442
|
+
*
|
|
443
|
+
* notify.on('connected', () => console.log('Connected!'));
|
|
444
|
+
* notify.on('notification', (n) => console.log('New notification:', n.title));
|
|
445
|
+
*
|
|
446
|
+
* await notify.connect();
|
|
447
|
+
*
|
|
448
|
+
* const alerts = notify.subscribe('alerts');
|
|
449
|
+
* alerts.on('notification', (n) => console.log(n.title));
|
|
450
|
+
* ```
|
|
451
|
+
*/
|
|
452
|
+
class NoLagNotify extends EventEmitter {
|
|
453
|
+
constructor(token, options = {}) {
|
|
454
|
+
super();
|
|
455
|
+
this._client = null;
|
|
456
|
+
this._channels = new Map();
|
|
457
|
+
this._lobby = null;
|
|
458
|
+
this._badgeManager = new BadgeManager();
|
|
459
|
+
this._presenceManager = new PresenceManager();
|
|
460
|
+
this._actorToUserId = new Map();
|
|
461
|
+
this._token = token;
|
|
462
|
+
this._userId = generateId();
|
|
463
|
+
this._options = {
|
|
464
|
+
metadata: options.metadata,
|
|
465
|
+
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
466
|
+
url: options.url,
|
|
467
|
+
maxNotificationCache: options.maxNotificationCache ?? DEFAULT_MAX_NOTIFICATION_CACHE,
|
|
468
|
+
debug: options.debug ?? false,
|
|
469
|
+
reconnect: options.reconnect ?? true,
|
|
470
|
+
channels: options.channels ?? [],
|
|
471
|
+
};
|
|
472
|
+
this._log = createLogger('NoLagNotify', this._options.debug);
|
|
473
|
+
}
|
|
474
|
+
// ============ Public Properties ============
|
|
475
|
+
/** Whether the underlying connection is established */
|
|
476
|
+
get connected() {
|
|
477
|
+
return this._client?.connected ?? false;
|
|
478
|
+
}
|
|
479
|
+
/** All currently subscribed channels */
|
|
480
|
+
get channels() {
|
|
481
|
+
return this._channels;
|
|
482
|
+
}
|
|
483
|
+
// ============ Lifecycle ============
|
|
484
|
+
/**
|
|
485
|
+
* Connect to NoLag and set up global presence.
|
|
486
|
+
*/
|
|
487
|
+
async connect() {
|
|
488
|
+
this._log('Connecting...');
|
|
489
|
+
const clientOptions = {
|
|
490
|
+
debug: this._options.debug,
|
|
491
|
+
reconnect: this._options.reconnect,
|
|
492
|
+
};
|
|
493
|
+
if (this._options.url) {
|
|
494
|
+
clientOptions.url = this._options.url;
|
|
495
|
+
}
|
|
496
|
+
this._client = jsSdk.NoLag(this._token, clientOptions);
|
|
497
|
+
// Wire client lifecycle events
|
|
498
|
+
this._client.on('connect', () => {
|
|
499
|
+
this._log('Connected');
|
|
500
|
+
if (this._channels.size > 0) {
|
|
501
|
+
this._log('Reconnected — restoring channels...');
|
|
502
|
+
this._restoreChannels();
|
|
503
|
+
this.emit('reconnected');
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
this._client.on('disconnect', (reason) => {
|
|
507
|
+
this._log('Disconnected:', reason);
|
|
508
|
+
this.emit('disconnected', reason);
|
|
509
|
+
});
|
|
510
|
+
this._client.on('reconnect', () => {
|
|
511
|
+
this._log('Reconnecting...');
|
|
512
|
+
});
|
|
513
|
+
this._client.on('error', (error) => {
|
|
514
|
+
this._log('Error:', error);
|
|
515
|
+
this.emit('error', error);
|
|
516
|
+
});
|
|
517
|
+
// Wire replay events
|
|
518
|
+
this._client.on('replay:start', (data) => {
|
|
519
|
+
const event = data;
|
|
520
|
+
for (const channel of this._channels.values()) {
|
|
521
|
+
channel._handleReplayStart(event.count);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
this._client.on('replay:end', (data) => {
|
|
525
|
+
const event = data;
|
|
526
|
+
for (const channel of this._channels.values()) {
|
|
527
|
+
channel._handleReplayEnd(event.replayed);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
// Connect
|
|
531
|
+
await this._client.connect();
|
|
532
|
+
// Wire room-level presence events
|
|
533
|
+
this._client.on('presence:join', (data) => {
|
|
534
|
+
this._handleRoomPresenceJoin(data);
|
|
535
|
+
});
|
|
536
|
+
this._client.on('presence:leave', (data) => {
|
|
537
|
+
this._handleRoomPresenceLeave(data);
|
|
538
|
+
});
|
|
539
|
+
this._client.on('presence:update', (data) => {
|
|
540
|
+
this._handleRoomPresenceUpdate(data);
|
|
541
|
+
});
|
|
542
|
+
this._log('Local userId:', this._userId, '→ actorId:', this._client.actorId);
|
|
543
|
+
// Set up lobby for global presence
|
|
544
|
+
await this._setupLobby();
|
|
545
|
+
// Pre-subscribe to all configured channels
|
|
546
|
+
for (const channelName of this._options.channels) {
|
|
547
|
+
this._subscribeChannel(channelName);
|
|
548
|
+
}
|
|
549
|
+
// Emit connected now that lobby is ready
|
|
550
|
+
this.emit('connected');
|
|
551
|
+
// Deferred lobby refetch to catch late-joining users
|
|
552
|
+
setTimeout(() => {
|
|
553
|
+
if (this._lobby && this._client?.connected) {
|
|
554
|
+
this._lobby.fetchPresence().then((state) => {
|
|
555
|
+
this._hydratePresence(state);
|
|
556
|
+
}).catch(() => { });
|
|
557
|
+
}
|
|
558
|
+
}, 2000);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Disconnect from NoLag and clean up all channels.
|
|
562
|
+
*/
|
|
563
|
+
disconnect() {
|
|
564
|
+
this._log('Disconnecting...');
|
|
565
|
+
for (const name of [...this._channels.keys()]) {
|
|
566
|
+
this.unsubscribe(name);
|
|
567
|
+
}
|
|
568
|
+
this._lobby?.unsubscribe();
|
|
569
|
+
this._lobby = null;
|
|
570
|
+
this._client?.disconnect();
|
|
571
|
+
this._client = null;
|
|
572
|
+
this._badgeManager.clear();
|
|
573
|
+
this._presenceManager.clear();
|
|
574
|
+
this._actorToUserId.clear();
|
|
575
|
+
}
|
|
576
|
+
// ============ Channel Management ============
|
|
577
|
+
/**
|
|
578
|
+
* Subscribe to a notification channel (idempotent).
|
|
579
|
+
* Returns the NotifyChannel instance.
|
|
580
|
+
*/
|
|
581
|
+
subscribe(channelName) {
|
|
582
|
+
if (!this._client) {
|
|
583
|
+
throw new Error('Not connected — call connect() first');
|
|
584
|
+
}
|
|
585
|
+
const existing = this._channels.get(channelName);
|
|
586
|
+
if (existing)
|
|
587
|
+
return existing;
|
|
588
|
+
const channel = this._subscribeChannel(channelName);
|
|
589
|
+
channel._activate();
|
|
590
|
+
return channel;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Unsubscribe from a notification channel.
|
|
594
|
+
*/
|
|
595
|
+
unsubscribe(channelName) {
|
|
596
|
+
const channel = this._channels.get(channelName);
|
|
597
|
+
if (!channel)
|
|
598
|
+
return;
|
|
599
|
+
this._log('Unsubscribing channel:', channelName);
|
|
600
|
+
channel._cleanup();
|
|
601
|
+
this._channels.delete(channelName);
|
|
602
|
+
this._badgeManager.update(channelName, 0);
|
|
603
|
+
this._emitBadgeUpdated();
|
|
604
|
+
}
|
|
605
|
+
// ============ Badge Counts ============
|
|
606
|
+
/**
|
|
607
|
+
* Get the current badge counts across all channels.
|
|
608
|
+
*/
|
|
609
|
+
getBadgeCounts() {
|
|
610
|
+
return this._badgeManager.getAll();
|
|
611
|
+
}
|
|
612
|
+
// ============ Read Tracking ============
|
|
613
|
+
/**
|
|
614
|
+
* Mark all notifications as read across all channels.
|
|
615
|
+
*/
|
|
616
|
+
markAllRead() {
|
|
617
|
+
for (const channel of this._channels.values()) {
|
|
618
|
+
channel.markAllRead();
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// ============ Private: Channel Setup ============
|
|
622
|
+
_subscribeChannel(name) {
|
|
623
|
+
if (!this._client) {
|
|
624
|
+
throw new Error('Not connected — call connect() first');
|
|
625
|
+
}
|
|
626
|
+
this._log('Subscribing channel:', name);
|
|
627
|
+
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
628
|
+
const channel = new NotifyChannel(name, roomContext, this._options, createLogger(`NotifyChannel:${name}`, this._options.debug));
|
|
629
|
+
this._channels.set(name, channel);
|
|
630
|
+
channel._subscribe();
|
|
631
|
+
// Relay notifications up to the main client and update badges
|
|
632
|
+
channel.on('notification', (notification) => {
|
|
633
|
+
this._badgeManager.update(name, channel.unreadCount);
|
|
634
|
+
this._emitBadgeUpdated();
|
|
635
|
+
this.emit('notification', notification);
|
|
636
|
+
});
|
|
637
|
+
channel.on('read', () => {
|
|
638
|
+
this._badgeManager.update(name, channel.unreadCount);
|
|
639
|
+
this._emitBadgeUpdated();
|
|
640
|
+
});
|
|
641
|
+
channel.on('readAll', () => {
|
|
642
|
+
this._badgeManager.update(name, 0);
|
|
643
|
+
this._emitBadgeUpdated();
|
|
644
|
+
});
|
|
645
|
+
return channel;
|
|
646
|
+
}
|
|
647
|
+
_emitBadgeUpdated() {
|
|
648
|
+
this.emit('badgeUpdated', this._badgeManager.getAll());
|
|
649
|
+
}
|
|
650
|
+
// ============ Private: Room Presence ============
|
|
651
|
+
_handleRoomPresenceJoin(data) {
|
|
652
|
+
if (data.actorTokenId === this._client?.actorId)
|
|
653
|
+
return;
|
|
654
|
+
const presenceData = data.presence;
|
|
655
|
+
if (!presenceData?.userId)
|
|
656
|
+
return;
|
|
657
|
+
const user = this._presenceManager.addFromPresence(data.actorTokenId, presenceData);
|
|
658
|
+
if (user) {
|
|
659
|
+
this._actorToUserId.set(data.actorTokenId, user.userId);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
_handleRoomPresenceLeave(data) {
|
|
663
|
+
if (data.actorTokenId === this._client?.actorId)
|
|
664
|
+
return;
|
|
665
|
+
this._presenceManager.removeByActorId(data.actorTokenId);
|
|
666
|
+
}
|
|
667
|
+
_handleRoomPresenceUpdate(data) {
|
|
668
|
+
if (data.actorTokenId === this._client?.actorId)
|
|
669
|
+
return;
|
|
670
|
+
const presenceData = data.presence;
|
|
671
|
+
if (!presenceData?.userId)
|
|
672
|
+
return;
|
|
673
|
+
this._presenceManager.addFromPresence(data.actorTokenId, presenceData);
|
|
674
|
+
}
|
|
675
|
+
// ============ Private: Lobby ============
|
|
676
|
+
async _setupLobby() {
|
|
677
|
+
if (!this._client)
|
|
678
|
+
return;
|
|
679
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
680
|
+
// Set local presence in the lobby
|
|
681
|
+
const presenceData = {
|
|
682
|
+
userId: this._userId,
|
|
683
|
+
metadata: this._options.metadata,
|
|
684
|
+
};
|
|
685
|
+
this._lobby.setPresence?.(presenceData);
|
|
686
|
+
const lobbyHandler = (type) => (data) => {
|
|
687
|
+
const event = data;
|
|
688
|
+
if (type === 'join')
|
|
689
|
+
this._handleLobbyJoin(event);
|
|
690
|
+
else if (type === 'leave')
|
|
691
|
+
this._handleLobbyLeave(event);
|
|
692
|
+
else
|
|
693
|
+
this._handleLobbyUpdate(event);
|
|
694
|
+
};
|
|
695
|
+
this._client.on('lobbyPresence:join', lobbyHandler('join'));
|
|
696
|
+
this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
|
|
697
|
+
this._client.on('lobbyPresence:update', lobbyHandler('update'));
|
|
698
|
+
try {
|
|
699
|
+
const initialState = await this._lobby.subscribe();
|
|
700
|
+
this._hydratePresence(initialState);
|
|
701
|
+
this._log('Lobby subscribed');
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
this._log('Lobby subscription failed:', err);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
_handleLobbyJoin(event) {
|
|
708
|
+
const { actorId, data } = event;
|
|
709
|
+
if (actorId === this._client?.actorId)
|
|
710
|
+
return;
|
|
711
|
+
const presenceData = data;
|
|
712
|
+
if (!presenceData?.userId)
|
|
713
|
+
return;
|
|
714
|
+
const user = this._presenceManager.addFromPresence(actorId, presenceData);
|
|
715
|
+
if (user) {
|
|
716
|
+
this._actorToUserId.set(actorId, user.userId);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
_handleLobbyLeave(event) {
|
|
720
|
+
const { actorId } = event;
|
|
721
|
+
if (actorId === this._client?.actorId)
|
|
722
|
+
return;
|
|
723
|
+
this._presenceManager.removeByActorId(actorId);
|
|
724
|
+
this._actorToUserId.delete(actorId);
|
|
725
|
+
}
|
|
726
|
+
_handleLobbyUpdate(event) {
|
|
727
|
+
const { actorId, data } = event;
|
|
728
|
+
if (actorId === this._client?.actorId)
|
|
729
|
+
return;
|
|
730
|
+
const presenceData = data;
|
|
731
|
+
if (!presenceData?.userId)
|
|
732
|
+
return;
|
|
733
|
+
this._presenceManager.addFromPresence(actorId, presenceData);
|
|
734
|
+
}
|
|
735
|
+
_hydratePresence(state) {
|
|
736
|
+
for (const roomId of Object.keys(state)) {
|
|
737
|
+
const roomPresence = state[roomId];
|
|
738
|
+
for (const actorId of Object.keys(roomPresence)) {
|
|
739
|
+
if (actorId === this._client?.actorId)
|
|
740
|
+
continue;
|
|
741
|
+
const raw = roomPresence[actorId];
|
|
742
|
+
const presenceData = (raw?.presence ?? raw);
|
|
743
|
+
if (presenceData?.userId) {
|
|
744
|
+
const user = this._presenceManager.addFromPresence(actorId, presenceData);
|
|
745
|
+
if (user) {
|
|
746
|
+
this._actorToUserId.set(actorId, user.userId);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
// ============ Private: Reconnect ============
|
|
753
|
+
_restoreChannels() {
|
|
754
|
+
this._lobby?.fetchPresence().then((state) => {
|
|
755
|
+
this._presenceManager.clear();
|
|
756
|
+
this._actorToUserId.clear();
|
|
757
|
+
this._hydratePresence(state);
|
|
758
|
+
}).catch((err) => {
|
|
759
|
+
this._log('Failed to re-fetch lobby presence:', err);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
exports.EventEmitter = EventEmitter;
|
|
765
|
+
exports.NoLagNotify = NoLagNotify;
|
|
766
|
+
exports.NotifyChannel = NotifyChannel;
|
|
767
|
+
//# sourceMappingURL=index.cjs.map
|