@massalabs/gossip-sdk 0.0.2-dev.20260226131013 → 0.0.2-dev.20260302112618

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.
@@ -57,6 +57,17 @@ export interface AnnouncementsConfig {
57
57
  /** Delay before retrying a failed announcement send in ms (default: 15000 = 15 seconds) */
58
58
  retryDelayMs: number;
59
59
  }
60
+ /**
61
+ * Session recovery configuration
62
+ */
63
+ export interface SessionRecoveryConfig {
64
+ /** Delay before retrying a killed session reset (default: 15 minutes) */
65
+ killedRetryDelayMs: number;
66
+ /** Jitter applied (default: 2 minutes) */
67
+ JitterMs: number;
68
+ /** Delay before retrying a saturated session reset (default: 5 minutes) */
69
+ saturatedRetryDelayMs: number;
70
+ }
60
71
  /**
61
72
  * Complete SDK configuration
62
73
  */
@@ -69,6 +80,8 @@ export interface SdkConfig {
69
80
  messages: MessagesConfig;
70
81
  /** Announcement settings */
71
82
  announcements: AnnouncementsConfig;
83
+ /** Session recovery settings */
84
+ sessionRecovery: SessionRecoveryConfig;
72
85
  }
73
86
  /**
74
87
  * Default SDK configuration values
@@ -29,6 +29,11 @@ export const defaultSdkConfig = {
29
29
  brokenThresholdMs: 60 * 60 * 1000, // 1 hour
30
30
  retryDelayMs: 15000, // 15 seconds
31
31
  },
32
+ sessionRecovery: {
33
+ killedRetryDelayMs: 15 * 60 * 1000,
34
+ JitterMs: 2 * 60 * 1000,
35
+ saturatedRetryDelayMs: 5 * 60 * 1000,
36
+ },
32
37
  };
33
38
  /**
34
39
  * Deep merge partial config with defaults
@@ -53,5 +58,9 @@ export function mergeConfig(partial) {
53
58
  ...defaultSdkConfig.announcements,
54
59
  ...partial.announcements,
55
60
  },
61
+ sessionRecovery: {
62
+ ...defaultSdkConfig.sessionRecovery,
63
+ ...partial.sessionRecovery,
64
+ },
56
65
  };
57
66
  }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Type-safe event emitter for SDK events.
5
5
  */
6
- import type { Message, Discussion, Contact } from '../db/index.js';
6
+ import type { Message, Discussion, Contact } from '../db';
7
+ import type { SessionStatus } from '../wasm/bindings';
7
8
  export declare enum SdkEventType {
8
9
  MESSAGE_RECEIVED = "messageReceived",
9
10
  MESSAGE_SENT = "messageSent",
@@ -14,6 +15,7 @@ export declare enum SdkEventType {
14
15
  SESSION_RENEWED = "sessionRenewed",
15
16
  SESSION_ACCEPTED = "sessionAccepted",
16
17
  SEEKERS_UPDATED = "seekersUpdated",
18
+ SESSION_STATUS_CHANGED = "sessionStatusChanged",
17
19
  ERROR = "error"
18
20
  }
19
21
  export interface SdkEventHandlers {
@@ -26,6 +28,7 @@ export interface SdkEventHandlers {
26
28
  [SdkEventType.SESSION_RENEWED]: (discussion: Discussion) => void;
27
29
  [SdkEventType.SESSION_ACCEPTED]: (contactUserId: string) => void;
28
30
  [SdkEventType.SEEKERS_UPDATED]: (seekers: Uint8Array[]) => void;
31
+ [SdkEventType.SESSION_STATUS_CHANGED]: (contactUserId: string, status: SessionStatus) => void;
29
32
  [SdkEventType.ERROR]: (error: Error, context: string) => void;
30
33
  }
31
34
  export declare class SdkEventEmitter {
@@ -17,6 +17,7 @@ export var SdkEventType;
17
17
  SdkEventType["SESSION_RENEWED"] = "sessionRenewed";
18
18
  SdkEventType["SESSION_ACCEPTED"] = "sessionAccepted";
19
19
  SdkEventType["SEEKERS_UPDATED"] = "seekersUpdated";
20
+ SdkEventType["SESSION_STATUS_CHANGED"] = "sessionStatusChanged";
20
21
  SdkEventType["ERROR"] = "error";
21
22
  })(SdkEventType || (SdkEventType = {}));
22
23
  // ─────────────────────────────────────────────────────────────────────────────
@@ -38,6 +39,7 @@ export class SdkEventEmitter {
38
39
  [SdkEventType.SESSION_RENEWED]: new Set(),
39
40
  [SdkEventType.SESSION_ACCEPTED]: new Set(),
40
41
  [SdkEventType.SEEKERS_UPDATED]: new Set(),
42
+ [SdkEventType.SESSION_STATUS_CHANGED]: new Set(),
41
43
  [SdkEventType.ERROR]: new Set(),
42
44
  }
43
45
  });
@@ -12,6 +12,8 @@ export interface PollingCallbacks {
12
12
  fetchAnnouncements: () => Promise<void>;
13
13
  /** Handle session refresh (state update) */
14
14
  handleSessionRefresh: () => Promise<void>;
15
+ /** Check for session status changes and emit events */
16
+ refreshSessionsStatusEvent: () => Promise<void>;
15
17
  }
16
18
  export declare class SdkPolling {
17
19
  private timers;
@@ -4,6 +4,7 @@
4
4
  * Manages polling timers for messages, announcements, and session refresh.
5
5
  */
6
6
  import { SdkEventType } from './SdkEventEmitter.js';
7
+ const SESSION_STATUS_POLL_INTERVAL_MS = 3000;
7
8
  // ─────────────────────────────────────────────────────────────────────────────
8
9
  // Polling Manager Class
9
10
  // ─────────────────────────────────────────────────────────────────────────────
@@ -17,6 +18,7 @@ export class SdkPolling {
17
18
  messages: null,
18
19
  announcements: null,
19
20
  sessionRefresh: null,
21
+ sessionStatus: null,
20
22
  }
21
23
  });
22
24
  Object.defineProperty(this, "callbacks", {
@@ -75,6 +77,16 @@ export class SdkPolling {
75
77
  this.eventEmitter?.emit(SdkEventType.ERROR, err, 'session_update');
76
78
  }
77
79
  }, config.polling.sessionRefreshIntervalMs);
80
+ // Start session status change polling (fixed 3s interval)
81
+ this.timers.sessionStatus = setInterval(async () => {
82
+ try {
83
+ await this.callbacks?.refreshSessionsStatusEvent();
84
+ }
85
+ catch (error) {
86
+ const err = error instanceof Error ? error : new Error(String(error));
87
+ this.eventEmitter?.emit(SdkEventType.ERROR, err, 'session_status_polling');
88
+ }
89
+ }, SESSION_STATUS_POLL_INTERVAL_MS);
78
90
  }
79
91
  /**
80
92
  * Stop all polling timers.
@@ -92,6 +104,10 @@ export class SdkPolling {
92
104
  clearInterval(this.timers.sessionRefresh);
93
105
  this.timers.sessionRefresh = null;
94
106
  }
107
+ if (this.timers.sessionStatus) {
108
+ clearInterval(this.timers.sessionStatus);
109
+ this.timers.sessionStatus = null;
110
+ }
95
111
  this.callbacks = null;
96
112
  this.eventEmitter = null;
97
113
  }
@@ -101,6 +117,7 @@ export class SdkPolling {
101
117
  isRunning() {
102
118
  return (this.timers.messages !== null ||
103
119
  this.timers.announcements !== null ||
104
- this.timers.sessionRefresh !== null);
120
+ this.timers.sessionRefresh !== null ||
121
+ this.timers.sessionStatus !== null);
105
122
  }
106
123
  }
package/dist/db/db.d.ts CHANGED
@@ -65,14 +65,6 @@ export interface UserProfile {
65
65
  updatedAt: Date;
66
66
  lastPublicKeyPush?: Date;
67
67
  }
68
- export declare enum DiscussionStatus {
69
- PENDING = "pending",
70
- ACTIVE = "active",
71
- CLOSED = "closed",// closed by the user
72
- BROKEN = "broken",// The session is killed. Need to be reinitiated
73
- SEND_FAILED = "sendFailed",// The discussion was initiated by the session manager but could not be broadcasted on network
74
- RECONNECTING = "reconnecting"
75
- }
76
68
  export declare enum DiscussionDirection {
77
69
  RECEIVED = "received",
78
70
  INITIATED = "initiated"
@@ -128,7 +120,6 @@ export interface Discussion {
128
120
  weAccepted: boolean;
129
121
  sendAnnouncement: SendAnnouncement;
130
122
  direction: DiscussionDirection;
131
- status: DiscussionStatus;
132
123
  nextSeeker: Uint8Array | null;
133
124
  initiationAnnouncement: Uint8Array | null;
134
125
  announcementMessage: string | null;
@@ -139,6 +130,9 @@ export interface Discussion {
139
130
  lastMessageContent: string | null;
140
131
  lastMessageTimestamp: Date | null;
141
132
  unreadCount: number;
133
+ killedNextRetryAt?: Date | null;
134
+ saturatedRetryAt?: Date | null;
135
+ saturatedRetryDone: boolean;
142
136
  createdAt: Date;
143
137
  updatedAt: Date;
144
138
  }
package/dist/db/db.js CHANGED
@@ -7,15 +7,6 @@
7
7
  // Constants
8
8
  export const MESSAGE_ID_SIZE = 12;
9
9
  // Unified discussion interface combining protocol state and UI metadata
10
- export var DiscussionStatus;
11
- (function (DiscussionStatus) {
12
- DiscussionStatus["PENDING"] = "pending";
13
- DiscussionStatus["ACTIVE"] = "active";
14
- DiscussionStatus["CLOSED"] = "closed";
15
- DiscussionStatus["BROKEN"] = "broken";
16
- DiscussionStatus["SEND_FAILED"] = "sendFailed";
17
- DiscussionStatus["RECONNECTING"] = "reconnecting";
18
- })(DiscussionStatus || (DiscussionStatus = {}));
19
10
  export var DiscussionDirection;
20
11
  (function (DiscussionDirection) {
21
12
  DiscussionDirection["RECEIVED"] = "received";
@@ -4,7 +4,7 @@ export const MIGRATIONS = [
4
4
  {
5
5
  idx: 0,
6
6
  tag: '0000_nifty_molly_hayes',
7
- when: 1771825473415,
7
+ when: 1730000000000,
8
8
  statements: [
9
9
  'CREATE TABLE `activeSeekers` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`seeker` blob NOT NULL\n);',
10
10
  'CREATE INDEX `active_seekers_seeker_idx` ON `activeSeekers` (`seeker`);',
@@ -12,9 +12,8 @@ export const MIGRATIONS = [
12
12
  'CREATE TABLE `contacts` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`ownerUserId` text NOT NULL,\n\t`userId` text NOT NULL,\n\t`name` text NOT NULL,\n\t`avatar` text,\n\t`publicKeys` blob NOT NULL,\n\t`isOnline` integer NOT NULL,\n\t`lastSeen` integer NOT NULL,\n\t`createdAt` integer NOT NULL\n);',
13
13
  'CREATE INDEX `contacts_owner_user_idx` ON `contacts` (`ownerUserId`,`userId`);',
14
14
  'CREATE INDEX `contacts_owner_name_idx` ON `contacts` (`ownerUserId`,`name`);',
15
- 'CREATE TABLE `discussions` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`ownerUserId` text NOT NULL,\n\t`contactUserId` text NOT NULL,\n\t`weAccepted` integer DEFAULT false NOT NULL,\n\t`sendAnnouncement` text,\n\t`direction` text NOT NULL,\n\t`status` text NOT NULL,\n\t`nextSeeker` blob,\n\t`initiationAnnouncement` blob,\n\t`announcementMessage` text,\n\t`lastSyncTimestamp` integer,\n\t`customName` text,\n\t`lastMessageId` integer,\n\t`lastMessageContent` text,\n\t`lastMessageTimestamp` integer,\n\t`unreadCount` integer DEFAULT 0 NOT NULL,\n\t`createdAt` integer NOT NULL,\n\t`updatedAt` integer NOT NULL\n);',
15
+ 'CREATE TABLE `discussions` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`ownerUserId` text NOT NULL,\n\t`contactUserId` text NOT NULL,\n\t`weAccepted` integer DEFAULT false NOT NULL,\n\t`sendAnnouncement` text,\n\t`direction` text NOT NULL,\n\t`nextSeeker` blob,\n\t`initiationAnnouncement` blob,\n\t`announcementMessage` text,\n\t`lastSyncTimestamp` integer,\n\t`customName` text,\n\t`lastMessageId` integer,\n\t`lastMessageContent` text,\n\t`lastMessageTimestamp` integer,\n\t`unreadCount` integer DEFAULT 0 NOT NULL,\n\t`killedNextRetryAt` integer,\n\t`saturatedRetryAt` integer,\n\t`saturatedRetryDone` integer DEFAULT 0 NOT NULL,\n\t`createdAt` integer NOT NULL,\n\t`updatedAt` integer NOT NULL\n);',
16
16
  'CREATE UNIQUE INDEX `discussions_owner_contact_idx` ON `discussions` (`ownerUserId`,`contactUserId`);',
17
- 'CREATE INDEX `discussions_owner_status_idx` ON `discussions` (`ownerUserId`,`status`);',
18
17
  'CREATE TABLE `messages` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`ownerUserId` text NOT NULL,\n\t`contactUserId` text NOT NULL,\n\t`messageId` blob,\n\t`content` text NOT NULL,\n\t`serializedContent` blob,\n\t`type` text NOT NULL,\n\t`direction` text NOT NULL,\n\t`status` text NOT NULL,\n\t`timestamp` integer NOT NULL,\n\t`metadata` text,\n\t`seeker` blob,\n\t`replyTo` text,\n\t`forwardOf` text,\n\t`encryptedMessage` blob,\n\t`whenToSend` integer\n);',
19
18
  'CREATE INDEX `messages_owner_contact_idx` ON `messages` (`ownerUserId`,`contactUserId`);',
20
19
  'CREATE INDEX `messages_owner_status_idx` ON `messages` (`ownerUserId`,`status`);',
@@ -23,6 +22,7 @@ export const MIGRATIONS = [
23
22
  'CREATE INDEX `messages_owner_contact_dir_idx` ON `messages` (`ownerUserId`,`contactUserId`,`direction`);',
24
23
  'CREATE INDEX `messages_owner_dir_status_idx` ON `messages` (`ownerUserId`,`direction`,`status`);',
25
24
  'CREATE INDEX `messages_timestamp_idx` ON `messages` (`timestamp`);',
25
+ 'CREATE INDEX `messages_owner_contact_msgid_idx` ON `messages` (`ownerUserId`,`contactUserId`,`messageId`);',
26
26
  'CREATE TABLE `pendingAnnouncements` (\n\t`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`announcement` blob NOT NULL,\n\t`fetchedAt` integer NOT NULL,\n\t`counter` text\n);',
27
27
  'CREATE UNIQUE INDEX `pending_announcements_announcement_idx` ON `pendingAnnouncements` (`announcement`);',
28
28
  'CREATE INDEX `pending_announcements_fetchedAt_idx` ON `pendingAnnouncements` (`fetchedAt`);',
@@ -34,12 +34,4 @@ export const MIGRATIONS = [
34
34
  'CREATE INDEX `userProfile_status_idx` ON `userProfile` (`status`);',
35
35
  ],
36
36
  },
37
- {
38
- idx: 1,
39
- tag: '0001_flowery_storm',
40
- when: 1771827090330,
41
- statements: [
42
- 'CREATE INDEX `messages_owner_contact_msgid_idx` ON `messages` (`ownerUserId`,`contactUserId`,`messageId`);',
43
- ],
44
- },
45
37
  ];
@@ -1,8 +1,7 @@
1
1
  import * as schema from '../schema/index.js';
2
2
  import type { DatabaseConnection } from '../sqlite.js';
3
- import type { DiscussionStatus } from '../db';
4
3
  export type DiscussionRow = typeof schema.discussions.$inferSelect;
5
- type DiscussionInsert = typeof schema.discussions.$inferInsert;
4
+ export type DiscussionInsert = typeof schema.discussions.$inferInsert;
6
5
  export declare class DiscussionQueries {
7
6
  private conn;
8
7
  constructor(conn: DatabaseConnection);
@@ -15,6 +14,4 @@ export declare class DiscussionQueries {
15
14
  deleteByOwnerAndContact(ownerUserId: string, contactUserId: string): Promise<void>;
16
15
  incrementUnreadCount(discussionId: number): Promise<void>;
17
16
  decrementUnreadCount(discussionId: number): Promise<void>;
18
- getByOwnerAndStatus(ownerUserId: string, status: DiscussionStatus): Promise<DiscussionRow[]>;
19
17
  }
20
- export {};
@@ -66,11 +66,4 @@ export class DiscussionQueries {
66
66
  })
67
67
  .where(and(eq(schema.discussions.id, discussionId), gt(schema.discussions.unreadCount, 0)));
68
68
  }
69
- async getByOwnerAndStatus(ownerUserId, status) {
70
- return this.conn.db
71
- .select()
72
- .from(schema.discussions)
73
- .where(and(eq(schema.discussions.ownerUserId, ownerUserId), eq(schema.discussions.status, status)))
74
- .all();
75
- }
76
69
  }
@@ -1,4 +1,4 @@
1
- import type { DiscussionDirection, DiscussionStatus } from '../db';
1
+ import type { DiscussionDirection } from '../db';
2
2
  export declare const discussions: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
3
3
  name: "discussions";
4
4
  schema: undefined;
@@ -114,26 +114,6 @@ export declare const discussions: import("drizzle-orm/sqlite-core").SQLiteTableW
114
114
  length: number | undefined;
115
115
  $type: DiscussionDirection;
116
116
  }>;
117
- status: import("drizzle-orm/sqlite-core").SQLiteColumn<{
118
- name: "status";
119
- tableName: "discussions";
120
- dataType: "string";
121
- columnType: "SQLiteText";
122
- data: DiscussionStatus;
123
- driverParam: string;
124
- notNull: true;
125
- hasDefault: false;
126
- isPrimaryKey: false;
127
- isAutoincrement: false;
128
- hasRuntimeDefault: false;
129
- enumValues: [string, ...string[]];
130
- baseColumn: never;
131
- identity: undefined;
132
- generated: undefined;
133
- }, {}, {
134
- length: number | undefined;
135
- $type: DiscussionStatus;
136
- }>;
137
117
  nextSeeker: import("drizzle-orm/sqlite-core").SQLiteColumn<{
138
118
  name: "nextSeeker";
139
119
  tableName: "discussions";
@@ -297,6 +277,57 @@ export declare const discussions: import("drizzle-orm/sqlite-core").SQLiteTableW
297
277
  identity: undefined;
298
278
  generated: undefined;
299
279
  }, {}, {}>;
280
+ killedNextRetryAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
281
+ name: "killedNextRetryAt";
282
+ tableName: "discussions";
283
+ dataType: "date";
284
+ columnType: "SQLiteTimestamp";
285
+ data: Date;
286
+ driverParam: number;
287
+ notNull: false;
288
+ hasDefault: false;
289
+ isPrimaryKey: false;
290
+ isAutoincrement: false;
291
+ hasRuntimeDefault: false;
292
+ enumValues: undefined;
293
+ baseColumn: never;
294
+ identity: undefined;
295
+ generated: undefined;
296
+ }, {}, {}>;
297
+ saturatedRetryAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
298
+ name: "saturatedRetryAt";
299
+ tableName: "discussions";
300
+ dataType: "date";
301
+ columnType: "SQLiteTimestamp";
302
+ data: Date;
303
+ driverParam: number;
304
+ notNull: false;
305
+ hasDefault: false;
306
+ isPrimaryKey: false;
307
+ isAutoincrement: false;
308
+ hasRuntimeDefault: false;
309
+ enumValues: undefined;
310
+ baseColumn: never;
311
+ identity: undefined;
312
+ generated: undefined;
313
+ }, {}, {}>;
314
+ saturatedRetryDone: import("drizzle-orm/sqlite-core").SQLiteColumn<{
315
+ name: "saturatedRetryDone";
316
+ tableName: "discussions";
317
+ dataType: "boolean";
318
+ columnType: "SQLiteBoolean";
319
+ data: boolean;
320
+ driverParam: number;
321
+ notNull: true;
322
+ hasDefault: true;
323
+ isPrimaryKey: false;
324
+ isAutoincrement: false;
325
+ hasRuntimeDefault: false;
326
+ enumValues: undefined;
327
+ baseColumn: never;
328
+ identity: undefined;
329
+ generated: undefined;
330
+ }, {}, {}>;
300
331
  createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
301
332
  name: "createdAt";
302
333
  tableName: "discussions";
@@ -1,4 +1,4 @@
1
- import { sqliteTable, text, integer, index, uniqueIndex, } from 'drizzle-orm/sqlite-core';
1
+ import { sqliteTable, text, integer, uniqueIndex, } from 'drizzle-orm/sqlite-core';
2
2
  import { bytes } from './_helpers.js';
3
3
  export const discussions = sqliteTable('discussions', {
4
4
  id: integer('id').primaryKey({ autoIncrement: true }),
@@ -9,7 +9,6 @@ export const discussions = sqliteTable('discussions', {
9
9
  .default(false),
10
10
  sendAnnouncement: text('sendAnnouncement'),
11
11
  direction: text('direction').$type().notNull(),
12
- status: text('status').$type().notNull(),
13
12
  nextSeeker: bytes('nextSeeker'),
14
13
  initiationAnnouncement: bytes('initiationAnnouncement'),
15
14
  announcementMessage: text('announcementMessage'),
@@ -23,9 +22,13 @@ export const discussions = sqliteTable('discussions', {
23
22
  mode: 'timestamp_ms',
24
23
  }),
25
24
  unreadCount: integer('unreadCount').notNull().default(0),
25
+ killedNextRetryAt: integer('killedNextRetryAt', { mode: 'timestamp_ms' }),
26
+ saturatedRetryAt: integer('saturatedRetryAt', { mode: 'timestamp_ms' }),
27
+ saturatedRetryDone: integer('saturatedRetryDone', { mode: 'boolean' })
28
+ .notNull()
29
+ .default(false),
26
30
  createdAt: integer('createdAt', { mode: 'timestamp_ms' }).notNull(),
27
31
  updatedAt: integer('updatedAt', { mode: 'timestamp_ms' }).notNull(),
28
32
  }, table => [
29
33
  uniqueIndex('discussions_owner_contact_idx').on(table.ownerUserId, table.contactUserId),
30
- index('discussions_owner_status_idx').on(table.ownerUserId, table.status),
31
34
  ]);
@@ -14,6 +14,9 @@ import * as SQLite from 'wa-sqlite';
14
14
  import { execStatements } from './exec-utils.js';
15
15
  let sqlite3 = null;
16
16
  let dbHandle = null;
17
+ // Add a message queue to process messages sequentially
18
+ const messageQueue = [];
19
+ let processing = false;
17
20
  // Bind Worker's postMessage (avoids DOM lib signature mismatch at compile time)
18
21
  const post = globalThis.postMessage.bind(globalThis);
19
22
  async function execSql(sql, params) {
@@ -28,7 +31,22 @@ async function execSql(sql, params) {
28
31
  }
29
32
  return { rows, lastInsertRowId };
30
33
  }
31
- addEventListener('message', async (e) => {
34
+ // Ensure that all messages are processed sequentially to avoid
35
+ // concurrent access to the underlying WASM/SQLite state, which
36
+ // can lead to heap corruption and "index out of bounds"/
37
+ // "unreachable executed" errors.
38
+ async function processMessageQueue() {
39
+ if (processing || messageQueue.length === 0)
40
+ return;
41
+ processing = true;
42
+ while (messageQueue.length > 0) {
43
+ const { e, resolve } = messageQueue.shift();
44
+ await handleMessage(e);
45
+ resolve();
46
+ }
47
+ processing = false;
48
+ }
49
+ async function handleMessage(e) {
32
50
  const { id, type } = e.data;
33
51
  try {
34
52
  switch (type) {
@@ -87,4 +105,13 @@ addEventListener('message', async (e) => {
87
105
  catch (err) {
88
106
  post({ id, type: 'error', message: err.message });
89
107
  }
108
+ }
109
+ addEventListener('message', (e) => {
110
+ // Chain each message onto the promise queue so that messages
111
+ // are handled strictly one after another.
112
+ let resolve;
113
+ const promise = new Promise(r => (resolve = r));
114
+ messageQueue.push({ e, resolve });
115
+ promise.then(() => processMessageQueue());
116
+ processMessageQueue();
90
117
  });
package/dist/gossip.js CHANGED
@@ -233,7 +233,7 @@ class GossipSdk {
233
233
  this._announcement = new AnnouncementService(messageProtocol, session, this.eventEmitter, config, queries);
234
234
  this._discussion = new DiscussionService(this._announcement, session, this.eventEmitter, queries);
235
235
  this._message = new MessageService(messageProtocol, session, this.eventEmitter, config, queries);
236
- this._refresh = new RefreshService(this._message, this._discussion, this._announcement, session, this.eventEmitter, queries);
236
+ this._refresh = new RefreshService(this._message, this._discussion, this._announcement, session, this.eventEmitter, queries, this.config);
237
237
  // Publish gossip ID (public key) on messageProtocol so the user is discoverable
238
238
  await this._auth.ensurePublicKeyPublished(session.ourPk, session.userIdEncoded);
239
239
  // Now set refreshService on services (circular dependency resolved via setter)
@@ -470,6 +470,9 @@ class GossipSdk {
470
470
  handleSessionRefresh: async () => {
471
471
  await this.updateState();
472
472
  },
473
+ refreshSessionsStatusEvent: async () => {
474
+ await this._refresh?.refreshSessionsStatusEvent();
475
+ },
473
476
  }, this.eventEmitter);
474
477
  }
475
478
  // ─────────────────────────────────────────────────────────────────
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Handles broadcasting and processing of session announcements.
5
5
  */
6
- import { DiscussionDirection, DiscussionStatus, MessageDirection, MessageStatus, MessageType, serializeSendAnnouncement, } from '../db/index.js';
6
+ import { DiscussionDirection, MessageDirection, MessageStatus, MessageType, serializeSendAnnouncement, } from '../db/index.js';
7
7
  import { toDiscussion } from '../utils/discussions.js';
8
8
  import { decodeUserId, encodeUserId } from '../utils/userId.js';
9
9
  import { SessionStatus } from '../wasm/bindings.js';
@@ -153,6 +153,9 @@ export class AnnouncementService {
153
153
  when_to_send: new Date(Date.now() + this.config.announcements.retryDelayMs),
154
154
  }),
155
155
  updatedAt: new Date(),
156
+ /* If send announcement failed, There are chances that the session will be killed again the next state_update call.
157
+ We don't want to wait a delay before reseting the session so we set killedNextRetryAt to null*/
158
+ killedNextRetryAt: null,
156
159
  });
157
160
  }
158
161
  }
@@ -432,7 +435,6 @@ export class AnnouncementService {
432
435
  ownerUserId,
433
436
  contactUserId,
434
437
  direction: DiscussionDirection.RECEIVED,
435
- status: DiscussionStatus.PENDING,
436
438
  announcementMessage: message,
437
439
  unreadCount: 0,
438
440
  createdAt: new Date(),
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Class-based service for initializing, accepting, and managing discussions.
5
5
  */
6
- import { DiscussionDirection, DiscussionStatus, MessageDirection, MessageStatus, MessageType, serializeSendAnnouncement, } from '../db/index.js';
6
+ import { DiscussionDirection, MessageDirection, MessageStatus, MessageType, serializeSendAnnouncement, } from '../db/index.js';
7
7
  import { toDiscussion, toSortedDiscussions, updateDiscussionName, } from '../utils/discussions.js';
8
8
  import { encodeAnnouncementPayload, } from '../utils/announcementPayload.js';
9
9
  import { UserPublicKeys } from '../wasm/bindings.js';
@@ -113,7 +113,6 @@ export class DiscussionService {
113
113
  contactUserId: contact.userId,
114
114
  weAccepted: true,
115
115
  direction: DiscussionDirection.INITIATED,
116
- status: DiscussionStatus.PENDING,
117
116
  unreadCount: 0,
118
117
  createdAt: new Date(),
119
118
  updatedAt: new Date(),
@@ -581,14 +581,19 @@ export class MessageService {
581
581
  }
582
582
  const peerId = decodeUserId(contactUserId);
583
583
  const sessionStatus = this.session.peerSessionStatus(peerId);
584
- if (sessionStatus !== SessionStatus.Active) {
585
- log.info('session not active, skipping send queue', {
584
+ if (![
585
+ SessionStatus.Active,
586
+ // saturated sessions can't send messages on session manager but it's still possible to send on network msg that have already been encrypted if any
587
+ SessionStatus.Saturated,
588
+ ].includes(sessionStatus)) {
589
+ log.info('session neither active nor saturated, skipping send queue', {
586
590
  contactUserId,
587
591
  sessionStatus: sessionStatusToString(sessionStatus),
588
592
  });
589
- // Not an error: queued messages should remain pending until the
590
- // handshake reaches Active.
591
- return { success: true, data: 0 };
593
+ return {
594
+ success: false,
595
+ error: new Error('Session neither active nor saturated'),
596
+ };
592
597
  }
593
598
  // retrieve all messages in send queue that need to be updated for this contact
594
599
  const pendingMessages = (await this.queries.messages.getSendQueue(ownerUserId, contactUserId)).map(rowToMessage);
@@ -613,7 +618,9 @@ export class MessageService {
613
618
  let encryptedMessage = msg.encryptedMessage;
614
619
  let seeker = msg.seeker;
615
620
  let whenToSend = msg.whenToSend;
616
- if (currentStatus === MessageStatus.WAITING_SESSION) {
621
+ // If the sessions is saturated it can't send messages on session manager
622
+ if (currentStatus === MessageStatus.WAITING_SESSION &&
623
+ sessionStatus === SessionStatus.Active) {
617
624
  let serializedContent = msg.serializedContent;
618
625
  if (!serializedContent) {
619
626
  const serializeResult = await this.serializeMessage(msg);
@@ -4,12 +4,13 @@
4
4
  * Class-based service to periodically refresh sessions and handle
5
5
  * keep-alive messages and broken sessions.
6
6
  */
7
+ import { type Queries } from '../db/index.js';
7
8
  import type { SessionModule } from '../wasm/session.js';
8
9
  import { MessageService } from './message.js';
9
10
  import { AnnouncementService } from './announcement.js';
10
11
  import { DiscussionService } from './discussion.js';
11
12
  import { SdkEventEmitter } from '../core/SdkEventEmitter.js';
12
- import { Queries } from '../db/queries/index.js';
13
+ import type { SdkConfig } from '../config/sdk.js';
13
14
  /**
14
15
  * Service for refreshing sessions and handling keep-alive messages.
15
16
  *
@@ -35,7 +36,14 @@ export declare class RefreshService {
35
36
  private isUpdating;
36
37
  private eventEmitter;
37
38
  private queries;
38
- constructor(messageService: MessageService, discussionService: DiscussionService, announcementService: AnnouncementService, session: SessionModule, eventEmitter: SdkEventEmitter, queries: Queries);
39
+ private config;
40
+ private sessionStatusMap;
41
+ constructor(messageService: MessageService, discussionService: DiscussionService, announcementService: AnnouncementService, session: SessionModule, eventEmitter: SdkEventEmitter, queries: Queries, config: SdkConfig);
42
+ /**
43
+ * Emit sessionStatusChanged events for discussions whose session status
44
+ * has changed since the last check.
45
+ */
46
+ refreshSessionsStatusEvent(): Promise<void>;
39
47
  /**
40
48
  * Update state for all discussions:
41
49
  * - Cleanup orphaned peers
@@ -46,4 +54,7 @@ export declare class RefreshService {
46
54
  * @param discussions - Array of discussions to process
47
55
  */
48
56
  stateUpdate(): Promise<void>;
57
+ private getJitteredDelayMs;
58
+ private updateSessionRecovery;
59
+ private handleSessionStatus;
49
60
  }
@@ -4,7 +4,7 @@
4
4
  * Class-based service to periodically refresh sessions and handle
5
5
  * keep-alive messages and broken sessions.
6
6
  */
7
- import { MessageType, MessageDirection, MessageStatus } from '../db/index.js';
7
+ import { MessageDirection, MessageStatus, MessageType, } from '../db/index.js';
8
8
  import { toSortedDiscussions } from '../utils/discussions.js';
9
9
  import { SessionStatus } from '../wasm/bindings.js';
10
10
  import { decodeUserId, encodeUserId } from '../utils/userId.js';
@@ -29,7 +29,7 @@ const logger = new Logger('RefreshService');
29
29
  * ```
30
30
  */
31
31
  export class RefreshService {
32
- constructor(messageService, discussionService, announcementService, session, eventEmitter, queries) {
32
+ constructor(messageService, discussionService, announcementService, session, eventEmitter, queries, config) {
33
33
  Object.defineProperty(this, "messageService", {
34
34
  enumerable: true,
35
35
  configurable: true,
@@ -72,12 +72,46 @@ export class RefreshService {
72
72
  writable: true,
73
73
  value: void 0
74
74
  });
75
+ Object.defineProperty(this, "config", {
76
+ enumerable: true,
77
+ configurable: true,
78
+ writable: true,
79
+ value: void 0
80
+ });
81
+ Object.defineProperty(this, "sessionStatusMap", {
82
+ enumerable: true,
83
+ configurable: true,
84
+ writable: true,
85
+ value: new Map()
86
+ });
75
87
  this.messageService = messageService;
76
88
  this.discussionService = discussionService;
77
89
  this.announcementService = announcementService;
78
90
  this.session = session;
79
91
  this.eventEmitter = eventEmitter;
80
92
  this.queries = queries;
93
+ this.config = config;
94
+ }
95
+ /**
96
+ * Emit sessionStatusChanged events for discussions whose session status
97
+ * has changed since the last check.
98
+ */
99
+ async refreshSessionsStatusEvent() {
100
+ const ownerUserId = this.session.userIdEncoded;
101
+ if (!ownerUserId) {
102
+ return;
103
+ }
104
+ const allRows = await this.queries.discussions.getByOwner(ownerUserId);
105
+ const discussions = toSortedDiscussions(allRows);
106
+ for (const discussion of discussions) {
107
+ const peerId = decodeUserId(discussion.contactUserId);
108
+ const status = this.session.peerSessionStatus(peerId);
109
+ const previous = this.sessionStatusMap.get(discussion.contactUserId);
110
+ if (previous !== status) {
111
+ this.sessionStatusMap.set(discussion.contactUserId, status);
112
+ this.eventEmitter.emit(SdkEventType.SESSION_STATUS_CHANGED, discussion.contactUserId, status);
113
+ }
114
+ }
81
115
  }
82
116
  /**
83
117
  * Update state for all discussions:
@@ -128,39 +162,27 @@ export class RefreshService {
128
162
  for (const discussion of discussions) {
129
163
  const peerId = decodeUserId(discussion.contactUserId);
130
164
  const status = this.session.peerSessionStatus(peerId);
131
- if ([
132
- SessionStatus.Killed,
133
- SessionStatus.Saturated,
134
- SessionStatus.NoSession,
135
- SessionStatus.UnknownPeer,
136
- ].includes(status)) {
137
- if (discussion.weAccepted) {
138
- const res = await this.discussionService.createSessionForContact(discussion.contactUserId, new Uint8Array(0));
139
- if (!res.success) {
140
- log.error('failed to create session for contact', {
141
- contactUserId: discussion.contactUserId,
142
- error: res.error,
143
- });
144
- }
145
- else {
146
- this.eventEmitter.emit(SdkEventType.SESSION_RENEWED, discussion);
147
- }
148
- }
149
- }
165
+ await this.handleSessionStatus(discussion, status);
150
166
  }
151
167
  // Step 2: send announcements
152
168
  const refreshRows = await this.queries.discussions.getByOwner(ownerUserId);
153
169
  const discussionsAfterRefresh = toSortedDiscussions(refreshRows);
154
170
  const activePendingDiscussions = discussionsAfterRefresh.filter(discussion => {
155
171
  const status = this.session.peerSessionStatus(decodeUserId(discussion.contactUserId));
156
- return [SessionStatus.Active, SessionStatus.SelfRequested].includes(status);
172
+ return [
173
+ SessionStatus.Active,
174
+ SessionStatus.SelfRequested,
175
+ SessionStatus.Saturated,
176
+ ].includes(status);
157
177
  });
158
178
  await this.announcementService.processOutgoingAnnouncements(activePendingDiscussions);
159
179
  // Step 3: send queued messages and keep-alives
160
- // NOTE: only flush queued messages once the session is fully Active.
180
+ // NOTE: only flush queued messages once the session is fully Active or saturated.
161
181
  // SelfRequested means the handshake is still establishing and
162
182
  // session.sendMessage() can return null.
163
- const activeEstablishedDiscussions = activePendingDiscussions.filter(discussion => this.session.peerSessionStatus(decodeUserId(discussion.contactUserId)) === SessionStatus.Active);
183
+ const activeEstablishedDiscussions = activePendingDiscussions.filter(discussion =>
184
+ // saturated sessions can't send messages on session manager but it's still possible to send on network msg that have already been encrypted if any
185
+ [SessionStatus.Active, SessionStatus.Saturated].includes(this.session.peerSessionStatus(decodeUserId(discussion.contactUserId))));
164
186
  const keepAliveSet = new Set(keepAlivePeerIds);
165
187
  for (const discussion of activeEstablishedDiscussions) {
166
188
  if (!discussion.weAccepted)
@@ -202,4 +224,126 @@ export class RefreshService {
202
224
  this.isUpdating = false;
203
225
  }
204
226
  }
227
+ getJitteredDelayMs(baseMs, jitterMs) {
228
+ const jitter = (Math.random() * 2 - 1) * jitterMs;
229
+ return Math.max(0, Math.round(baseMs + jitter));
230
+ }
231
+ async updateSessionRecovery(discussion, updates) {
232
+ if (!discussion.id) {
233
+ return;
234
+ }
235
+ if (!updates) {
236
+ // Clear all recovery fields
237
+ if ((discussion.killedNextRetryAt === null ||
238
+ discussion.killedNextRetryAt === undefined) &&
239
+ (discussion.saturatedRetryAt === null ||
240
+ discussion.saturatedRetryAt === undefined) &&
241
+ (discussion.saturatedRetryDone === null ||
242
+ discussion.saturatedRetryDone === undefined)) {
243
+ return; // Already cleared
244
+ }
245
+ const updateData = {
246
+ killedNextRetryAt: null,
247
+ saturatedRetryAt: null,
248
+ saturatedRetryDone: false,
249
+ };
250
+ await this.queries.discussions.updateById(discussion.id, updateData);
251
+ return;
252
+ }
253
+ const currentNormalized = {
254
+ killedNextRetryAt: discussion.killedNextRetryAt?.getTime() ?? null,
255
+ saturatedRetryAt: discussion.saturatedRetryAt?.getTime() ?? null,
256
+ };
257
+ const nextNormalized = {
258
+ killedNextRetryAt: updates.killedNextRetryAt?.getTime() ?? null,
259
+ saturatedRetryAt: updates.saturatedRetryAt?.getTime() ?? null,
260
+ };
261
+ const isSame = currentNormalized.killedNextRetryAt ===
262
+ nextNormalized.killedNextRetryAt &&
263
+ currentNormalized.saturatedRetryAt === nextNormalized.saturatedRetryAt &&
264
+ discussion.saturatedRetryDone === updates.saturatedRetryDone;
265
+ if (isSame) {
266
+ return;
267
+ }
268
+ const updateData = {
269
+ killedNextRetryAt: updates.killedNextRetryAt,
270
+ saturatedRetryAt: updates.saturatedRetryAt,
271
+ saturatedRetryDone: updates.saturatedRetryDone,
272
+ };
273
+ await this.queries.discussions.updateById(discussion.id, updateData);
274
+ }
275
+ async handleSessionStatus(discussion, status) {
276
+ const now = new Date();
277
+ const log = logger.forMethod('handleSessionStatus');
278
+ if (status === SessionStatus.Active) {
279
+ await this.updateSessionRecovery(discussion, undefined);
280
+ return;
281
+ }
282
+ if ([SessionStatus.SelfRequested, SessionStatus.PeerRequested].includes(status)) {
283
+ return;
284
+ }
285
+ if (!discussion.weAccepted) {
286
+ return;
287
+ }
288
+ if (status === SessionStatus.NoSession ||
289
+ status === SessionStatus.UnknownPeer) {
290
+ log.error('no session or unknown peer', {
291
+ contactUserId: discussion.contactUserId,
292
+ status: status,
293
+ });
294
+ return;
295
+ }
296
+ if (status === SessionStatus.Killed) {
297
+ const nextRetryAt = discussion.killedNextRetryAt;
298
+ if (nextRetryAt && nextRetryAt.getTime() > now.getTime()) {
299
+ return;
300
+ }
301
+ const res = await this.discussionService.createSessionForContact(discussion.contactUserId, new Uint8Array(0));
302
+ if (!res.success) {
303
+ log.error('failed to create session for contact', {
304
+ contactUserId: discussion.contactUserId,
305
+ error: res.error,
306
+ });
307
+ return; // if we failed to create session, we don't want to set killedNextRetryAt
308
+ }
309
+ else {
310
+ this.eventEmitter.emit(SdkEventType.SESSION_RENEWED, discussion);
311
+ }
312
+ const delayMs = this.getJitteredDelayMs(this.config.sessionRecovery.killedRetryDelayMs, this.config.sessionRecovery.JitterMs);
313
+ await this.updateSessionRecovery(discussion, {
314
+ killedNextRetryAt: new Date(now.getTime() + delayMs),
315
+ saturatedRetryDone: false,
316
+ });
317
+ return;
318
+ }
319
+ if (status === SessionStatus.Saturated) {
320
+ const retryAt = discussion.saturatedRetryAt;
321
+ if (discussion.saturatedRetryDone ||
322
+ (retryAt && retryAt.getTime() > now.getTime())) {
323
+ return;
324
+ }
325
+ if (!retryAt) {
326
+ const delayMs = this.getJitteredDelayMs(this.config.sessionRecovery.saturatedRetryDelayMs, this.config.sessionRecovery.JitterMs);
327
+ await this.updateSessionRecovery(discussion, {
328
+ saturatedRetryAt: new Date(now.getTime() + delayMs),
329
+ saturatedRetryDone: false,
330
+ });
331
+ return;
332
+ }
333
+ const res = await this.discussionService.createSessionForContact(discussion.contactUserId, new Uint8Array(0));
334
+ if (!res.success) {
335
+ log.error('failed to create session for contact', {
336
+ contactUserId: discussion.contactUserId,
337
+ error: res.error,
338
+ });
339
+ return; // if we failed to create session, we don't want to set saturatedRetryDone to true
340
+ }
341
+ else {
342
+ this.eventEmitter.emit(SdkEventType.SESSION_RENEWED, discussion);
343
+ }
344
+ await this.updateSessionRecovery(discussion, {
345
+ saturatedRetryDone: true,
346
+ });
347
+ }
348
+ }
205
349
  }
@@ -8,4 +8,4 @@ export { SessionModule, sessionStatusToString } from './session.js';
8
8
  export { initializeWasm, ensureWasmInitialized, startWasmInitialization, } from './loader.js';
9
9
  export * from './encryption.js';
10
10
  export * from './userKeys.js';
11
- export { SessionStatus, UserPublicKeys, UserSecretKeys, SendMessageOutput, ReceiveMessageOutput, AnnouncementResult, } from './bindings.js';
11
+ export { SessionStatus, SessionConfig, UserPublicKeys, UserSecretKeys, SendMessageOutput, ReceiveMessageOutput, AnnouncementResult, } from './bindings.js';
@@ -10,4 +10,4 @@ export { SessionModule, sessionStatusToString } from './session.js';
10
10
  export { initializeWasm, ensureWasmInitialized, startWasmInitialization, } from './loader.js';
11
11
  export * from './encryption.js';
12
12
  export * from './userKeys.js';
13
- export { SessionStatus, UserPublicKeys, UserSecretKeys, SendMessageOutput, ReceiveMessageOutput, AnnouncementResult, } from './bindings.js';
13
+ export { SessionStatus, SessionConfig, UserPublicKeys, UserSecretKeys, SendMessageOutput, ReceiveMessageOutput, AnnouncementResult, } from './bindings.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massalabs/gossip-sdk",
3
- "version": "0.0.2-dev.20260226131013",
3
+ "version": "0.0.2-dev.20260302112618",
4
4
  "description": "Gossip SDK for automation, chatbot, and integration use cases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",