@calimero-network/mero-js 23.4.2 → 23.4.3

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.
@@ -81,6 +81,16 @@ export function subscribePresence(sse, contextId, handler, codec = jsonCodec())
81
81
  handler(entry);
82
82
  };
83
83
  sse.on('event', listener);
84
+ // On a context this connection already holds, the node's one-time replay
85
+ // went to whichever listener came first: seed this one from what the
86
+ // client has kept, as the replay would have. (A context not held yet is
87
+ // replayed by the node on the subscribe below.) Optional: a test double
88
+ // or another event source may not keep presence.
89
+ if (sse.isSubscribed?.(contextId)) {
90
+ for (const data of sse.presenceSeed?.(contextId) ?? []) {
91
+ listener({ contextId, type: 'Ephemeral', data });
92
+ }
93
+ }
84
94
  // Errors surface via the SSE client's own 'error' event; nothing more to
85
95
  // do with them here.
86
96
  void sse.connect().catch(() => undefined);
@@ -41,6 +41,14 @@ export declare class SseClient {
41
41
  private reconnectTimer;
42
42
  private subscribedContextIds;
43
43
  private subscribedGroupIds;
44
+ /**
45
+ * The current presence of each subscribed context, as the node's replay and
46
+ * its live deltas left it: author → that author's last `Ephemeral` data and
47
+ * when it arrived. The node replays a context's presence once, when the
48
+ * context is first subscribed on this connection, so a presence listener
49
+ * that comes later would never see it; {@link presenceSeed} gives it this.
50
+ */
51
+ private presence;
44
52
  private closed;
45
53
  private listeners;
46
54
  private fetchImpl;
@@ -87,6 +95,15 @@ export declare class SseClient {
87
95
  contextIds?: string[];
88
96
  groupIds?: string[];
89
97
  }): Promise<void>;
98
+ /** Whether this connection holds `contextId`'s events. */
99
+ isSubscribed(contextId: string): boolean;
100
+ /**
101
+ * The presence currently known for `contextId`, as `Ephemeral` event data,
102
+ * each entry's `ageMs` advanced by how long ago it arrived. Empty for a
103
+ * context this connection does not hold.
104
+ */
105
+ presenceSeed(contextId: string): Array<Record<string, unknown>>;
106
+ private notePresence;
90
107
  unsubscribe(idsOrOpts: string[] | {
91
108
  contextIds?: string[];
92
109
  groupIds?: string[];
@@ -39,6 +39,14 @@ export class SseClient {
39
39
  this.reconnectTimer = null;
40
40
  this.subscribedContextIds = new Set();
41
41
  this.subscribedGroupIds = new Set();
42
+ /**
43
+ * The current presence of each subscribed context, as the node's replay and
44
+ * its live deltas left it: author → that author's last `Ephemeral` data and
45
+ * when it arrived. The node replays a context's presence once, when the
46
+ * context is first subscribed on this connection, so a presence listener
47
+ * that comes later would never see it; {@link presenceSeed} gives it this.
48
+ */
49
+ this.presence = new Map();
42
50
  this.closed = false;
43
51
  this.listeners = { connect: [], event: [], error: [] };
44
52
  this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
@@ -227,6 +235,9 @@ export class SseClient {
227
235
  if (msg.type === 'connect' && msg.session_id) {
228
236
  this.sessionId = msg.session_id;
229
237
  this.emit('connect', msg.session_id);
238
+ // A new connection gets a new replay when it re-subscribes; what the
239
+ // old one said may name authors who have since gone.
240
+ this.presence.clear();
230
241
  // Re-subscribe after reconnect
231
242
  if (this.subscribedContextIds.size > 0 || this.subscribedGroupIds.size > 0) {
232
243
  this.sendSubscription('subscribe', {
@@ -252,6 +263,8 @@ export class SseClient {
252
263
  }
253
264
  // Context event message
254
265
  if (msg.result.contextId) {
266
+ if (msg.result.type === 'Ephemeral')
267
+ this.notePresence(msg.result.contextId, eventData);
255
268
  this.emit('event', {
256
269
  contextId: msg.result.contextId,
257
270
  // Forward the event tag (sibling of `data` in core's flattened
@@ -289,12 +302,43 @@ export class SseClient {
289
302
  await this.sendSubscription('subscribe', { contextIds: newContextIds, groupIds: newGroupIds });
290
303
  }
291
304
  }
305
+ /** Whether this connection holds `contextId`'s events. */
306
+ isSubscribed(contextId) {
307
+ return this.subscribedContextIds.has(contextId);
308
+ }
309
+ /**
310
+ * The presence currently known for `contextId`, as `Ephemeral` event data,
311
+ * each entry's `ageMs` advanced by how long ago it arrived. Empty for a
312
+ * context this connection does not hold.
313
+ */
314
+ presenceSeed(contextId) {
315
+ const now = Date.now();
316
+ return [...(this.presence.get(contextId)?.values() ?? [])].map(({ data, at }) => ({
317
+ ...data,
318
+ ageMs: (typeof data.ageMs === 'number' ? data.ageMs : 0) + (now - at),
319
+ }));
320
+ }
321
+ notePresence(contextId, data) {
322
+ if (!this.subscribedContextIds.has(contextId))
323
+ return;
324
+ const d = data;
325
+ if (!d || typeof d.author !== 'string')
326
+ return;
327
+ let byAuthor = this.presence.get(contextId);
328
+ if (!byAuthor)
329
+ this.presence.set(contextId, (byAuthor = new Map()));
330
+ if (d.removed)
331
+ byAuthor.delete(d.author);
332
+ else
333
+ byAuthor.set(d.author, { data: d, at: Date.now() });
334
+ }
292
335
  async unsubscribe(idsOrOpts) {
293
336
  const opts = Array.isArray(idsOrOpts) ? { contextIds: idsOrOpts } : idsOrOpts;
294
337
  const hadContextIds = (opts.contextIds ?? []).filter(id => this.subscribedContextIds.has(id));
295
338
  const hadGroupIds = (opts.groupIds ?? []).filter(id => this.subscribedGroupIds.has(id));
296
339
  for (const id of opts.contextIds ?? []) {
297
340
  this.subscribedContextIds.delete(id);
341
+ this.presence.delete(id);
298
342
  }
299
343
  for (const id of opts.groupIds ?? []) {
300
344
  this.subscribedGroupIds.delete(id);