@massalabs/gossip-sdk 0.0.2-dev.20260410113001 → 0.0.2-dev.20260410113627
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/dist/services/message.d.ts +24 -0
- package/dist/services/message.js +301 -46
- package/package.json +1 -1
|
@@ -45,6 +45,14 @@ export declare class MessageService {
|
|
|
45
45
|
private processingContacts;
|
|
46
46
|
private isFetchingMessages;
|
|
47
47
|
private queries;
|
|
48
|
+
/**
|
|
49
|
+
* Message ids currently being sent via `sendMessageFastPath`. The row is
|
|
50
|
+
* WAITING_SESSION in the DB while the fast path runs INSERT + encrypt +
|
|
51
|
+
* POST in parallel. Without this guard, a concurrent `stateUpdate` could
|
|
52
|
+
* pick up the same WAITING_SESSION row via `processSendQueueForContact`
|
|
53
|
+
* and send a duplicate.
|
|
54
|
+
*/
|
|
55
|
+
private inFlightFastPath;
|
|
48
56
|
/** Emit MESSAGE_RECEIVED with a Message that may not have a DB id yet */
|
|
49
57
|
private emitMessageReceived;
|
|
50
58
|
constructor(messageProtocol: IMessageProtocol, session: SessionModule, eventEmitter: SdkEventEmitter, config: SdkConfig | undefined, queries: Queries);
|
|
@@ -66,6 +74,22 @@ export declare class MessageService {
|
|
|
66
74
|
findMessageBySeeker(seeker: Uint8Array, ownerUserId: string): Promise<Message | undefined>;
|
|
67
75
|
private acknowledgeMessages;
|
|
68
76
|
sendMessage(message: Message): Promise<SendMessageResult>;
|
|
77
|
+
/**
|
|
78
|
+
* Happy-path send: peer session is Active, so we can encrypt locally,
|
|
79
|
+
* fire the network POST, and INSERT the row in parallel. The three
|
|
80
|
+
* pieces are independent up until the final `UPDATE → SENT`, which
|
|
81
|
+
* needs both the row id (from INSERT) and a successful network ack.
|
|
82
|
+
*
|
|
83
|
+
* ┌── INSERT (~440 ms) ───────────────┐
|
|
84
|
+
* │ ├── UPDATE → SENT (background)
|
|
85
|
+
* └── encrypt (~150 ms) → POST (~620 ms)
|
|
86
|
+
* │
|
|
87
|
+
* └── emit MESSAGE_SENT
|
|
88
|
+
*
|
|
89
|
+
* Returns `null` if the fast path can't run (encrypt declined, network
|
|
90
|
+
* threw, etc.) so the caller can fall back to the slow path.
|
|
91
|
+
*/
|
|
92
|
+
private sendMessageFastPath;
|
|
69
93
|
private serializeMessage;
|
|
70
94
|
resendMessages(messages: Map<string, Message[]>): Promise<void>;
|
|
71
95
|
/**
|
package/dist/services/message.js
CHANGED
|
@@ -202,6 +202,19 @@ export class MessageService {
|
|
|
202
202
|
writable: true,
|
|
203
203
|
value: void 0
|
|
204
204
|
});
|
|
205
|
+
/**
|
|
206
|
+
* Message ids currently being sent via `sendMessageFastPath`. The row is
|
|
207
|
+
* WAITING_SESSION in the DB while the fast path runs INSERT + encrypt +
|
|
208
|
+
* POST in parallel. Without this guard, a concurrent `stateUpdate` could
|
|
209
|
+
* pick up the same WAITING_SESSION row via `processSendQueueForContact`
|
|
210
|
+
* and send a duplicate.
|
|
211
|
+
*/
|
|
212
|
+
Object.defineProperty(this, "inFlightFastPath", {
|
|
213
|
+
enumerable: true,
|
|
214
|
+
configurable: true,
|
|
215
|
+
writable: true,
|
|
216
|
+
value: new Set()
|
|
217
|
+
});
|
|
205
218
|
this.messageProtocol = messageProtocol;
|
|
206
219
|
this.session = session;
|
|
207
220
|
this.eventEmitter = eventEmitter;
|
|
@@ -297,42 +310,44 @@ export class MessageService {
|
|
|
297
310
|
* Add a message to SQLite and update the corresponding discussion.
|
|
298
311
|
*/
|
|
299
312
|
async addMessageAndUpdateDiscussion(message) {
|
|
300
|
-
|
|
301
|
-
messageId
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId);
|
|
321
|
-
if (discussion &&
|
|
322
|
-
message.type !== MessageType.KEEP_ALIVE &&
|
|
323
|
-
message.type !== MessageType.REACTION &&
|
|
324
|
-
message.type !== MessageType.RETENTION_POLICY) {
|
|
325
|
-
await this.queries.discussions.updateById(discussion.id, {
|
|
326
|
-
lastMessageId: messageId,
|
|
327
|
-
lastMessageContent: message.content,
|
|
328
|
-
lastMessageTimestamp: message.timestamp,
|
|
329
|
-
updatedAt: new Date(),
|
|
313
|
+
return this.queries.conn.withTransaction(async () => {
|
|
314
|
+
const messageId = await this.queries.messages.insert({
|
|
315
|
+
messageId: message.messageId,
|
|
316
|
+
ownerUserId: message.ownerUserId,
|
|
317
|
+
contactUserId: message.contactUserId,
|
|
318
|
+
content: message.content,
|
|
319
|
+
serializedContent: message.serializedContent,
|
|
320
|
+
type: message.type,
|
|
321
|
+
direction: message.direction,
|
|
322
|
+
status: message.status,
|
|
323
|
+
timestamp: message.timestamp,
|
|
324
|
+
metadata: serializeMetadata(message.metadata),
|
|
325
|
+
seeker: message.seeker,
|
|
326
|
+
replyTo: serializeReplyTo(message.replyTo),
|
|
327
|
+
forwardOf: serializeForwardOf(message.forwardOf),
|
|
328
|
+
deleteOf: serializeDeleteOf(message.deleteOf),
|
|
329
|
+
editOf: serializeEditOf(message.editOf),
|
|
330
|
+
reactionOf: serializeReactionOf(message.reactionOf),
|
|
331
|
+
encryptedMessage: message.encryptedMessage,
|
|
332
|
+
whenToSend: message.whenToSend,
|
|
330
333
|
});
|
|
331
|
-
|
|
332
|
-
|
|
334
|
+
const discussion = await this.queries.discussions.getByOwnerAndContact(message.ownerUserId, message.contactUserId);
|
|
335
|
+
if (discussion &&
|
|
336
|
+
message.type !== MessageType.KEEP_ALIVE &&
|
|
337
|
+
message.type !== MessageType.REACTION &&
|
|
338
|
+
message.type !== MessageType.RETENTION_POLICY) {
|
|
339
|
+
await this.queries.discussions.updateById(discussion.id, {
|
|
340
|
+
lastMessageId: messageId,
|
|
341
|
+
lastMessageContent: message.content,
|
|
342
|
+
lastMessageTimestamp: message.timestamp,
|
|
343
|
+
updatedAt: new Date(),
|
|
344
|
+
});
|
|
345
|
+
if (message.direction === MessageDirection.INCOMING) {
|
|
346
|
+
await this.queries.discussions.incrementUnreadCount(discussion.id);
|
|
347
|
+
}
|
|
333
348
|
}
|
|
334
|
-
|
|
335
|
-
|
|
349
|
+
return messageId;
|
|
350
|
+
});
|
|
336
351
|
}
|
|
337
352
|
async decryptMessages(encrypted) {
|
|
338
353
|
const log = logger.forMethod('decryptMessages');
|
|
@@ -647,7 +662,23 @@ export class MessageService {
|
|
|
647
662
|
: undefined;
|
|
648
663
|
message.messageId = randomMessageId;
|
|
649
664
|
}
|
|
650
|
-
//
|
|
665
|
+
// Fast path: if the peer's session is already Active we can encrypt
|
|
666
|
+
// and ship the message in parallel with the local INSERT instead of
|
|
667
|
+
// waiting for `addMessageAndUpdateDiscussion` to commit before
|
|
668
|
+
// `processSendQueueForContact` even starts. The user-perceived
|
|
669
|
+
// latency drops from `INSERT + encrypt + POST` (sequential) to
|
|
670
|
+
// `max(INSERT, encrypt + POST)` — ~440 ms saved on prod where the
|
|
671
|
+
// network round-trip dominates.
|
|
672
|
+
const sessionStatus = this.session.peerSessionStatus(peerId);
|
|
673
|
+
if (sessionStatus === SessionStatus.Active) {
|
|
674
|
+
const fastPathResult = await this.sendMessageFastPath(message, peerId);
|
|
675
|
+
if (fastPathResult) {
|
|
676
|
+
return fastPathResult;
|
|
677
|
+
}
|
|
678
|
+
// Fast path bailed (encrypt returned null, etc.) — fall back to
|
|
679
|
+
// the slow path below.
|
|
680
|
+
}
|
|
681
|
+
// Slow path: INSERT first, let stateUpdate handle the encrypt + send.
|
|
651
682
|
let messageId;
|
|
652
683
|
try {
|
|
653
684
|
messageId = await this.addMessageAndUpdateDiscussion({
|
|
@@ -676,6 +707,173 @@ export class MessageService {
|
|
|
676
707
|
message: queuedMessage,
|
|
677
708
|
};
|
|
678
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Happy-path send: peer session is Active, so we can encrypt locally,
|
|
712
|
+
* fire the network POST, and INSERT the row in parallel. The three
|
|
713
|
+
* pieces are independent up until the final `UPDATE → SENT`, which
|
|
714
|
+
* needs both the row id (from INSERT) and a successful network ack.
|
|
715
|
+
*
|
|
716
|
+
* ┌── INSERT (~440 ms) ───────────────┐
|
|
717
|
+
* │ ├── UPDATE → SENT (background)
|
|
718
|
+
* └── encrypt (~150 ms) → POST (~620 ms)
|
|
719
|
+
* │
|
|
720
|
+
* └── emit MESSAGE_SENT
|
|
721
|
+
*
|
|
722
|
+
* Returns `null` if the fast path can't run (encrypt declined, network
|
|
723
|
+
* threw, etc.) so the caller can fall back to the slow path.
|
|
724
|
+
*/
|
|
725
|
+
async sendMessageFastPath(message, peerId) {
|
|
726
|
+
const log = logger.forMethod('sendMessageFastPath');
|
|
727
|
+
// 1. Serialize synchronously — needed before encrypt.
|
|
728
|
+
const serializeResult = await this.serializeMessage(message);
|
|
729
|
+
if (!serializeResult.success) {
|
|
730
|
+
log.error('failed to serialize message for fast path', {
|
|
731
|
+
error: serializeResult.error,
|
|
732
|
+
});
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
const serializedContent = serializeResult.data;
|
|
736
|
+
// 2. Kick off the local INSERT and the encrypt in parallel.
|
|
737
|
+
// encrypt is fast (~150 ms with rayon) and doesn't depend on
|
|
738
|
+
// the row id; INSERT is slow (~440 ms) and only the row id is
|
|
739
|
+
// later needed for the SENT update.
|
|
740
|
+
const insertPromise = this.addMessageAndUpdateDiscussion({
|
|
741
|
+
...message,
|
|
742
|
+
status: MessageStatus.WAITING_SESSION,
|
|
743
|
+
})
|
|
744
|
+
.then(id => {
|
|
745
|
+
this.inFlightFastPath.add(id);
|
|
746
|
+
return id;
|
|
747
|
+
})
|
|
748
|
+
.catch(error => {
|
|
749
|
+
log.error('addMessageAndUpdateDiscussion failed in fast path', {
|
|
750
|
+
error,
|
|
751
|
+
});
|
|
752
|
+
return null;
|
|
753
|
+
});
|
|
754
|
+
let sendOutput;
|
|
755
|
+
try {
|
|
756
|
+
sendOutput = await this.session.sendMessage(peerId, serializedContent);
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
log.error('session.sendMessage threw in fast path, falling back', {
|
|
760
|
+
error,
|
|
761
|
+
});
|
|
762
|
+
sendOutput = undefined;
|
|
763
|
+
}
|
|
764
|
+
if (!sendOutput) {
|
|
765
|
+
// Session became inactive between status check and encrypt.
|
|
766
|
+
// Wait for the INSERT to land then bail out so the slow path
|
|
767
|
+
// can take over.
|
|
768
|
+
const messageId = await insertPromise;
|
|
769
|
+
if (messageId === null) {
|
|
770
|
+
return {
|
|
771
|
+
success: false,
|
|
772
|
+
error: 'Failed to add message to database',
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
log.info('encrypt returned null in fast path, falling back to slow path', { messageId });
|
|
776
|
+
this.inFlightFastPath.delete(messageId);
|
|
777
|
+
void this.refreshService?.stateUpdate();
|
|
778
|
+
return {
|
|
779
|
+
success: true,
|
|
780
|
+
message: {
|
|
781
|
+
...message,
|
|
782
|
+
id: messageId,
|
|
783
|
+
status: MessageStatus.WAITING_SESSION,
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
// 3. Encrypt succeeded. Network POST runs in parallel with the
|
|
788
|
+
// still-in-flight INSERT.
|
|
789
|
+
const networkPromise = this.messageProtocol
|
|
790
|
+
.sendMessage({
|
|
791
|
+
seeker: sendOutput.seeker,
|
|
792
|
+
ciphertext: sendOutput.data,
|
|
793
|
+
})
|
|
794
|
+
.then(() => true)
|
|
795
|
+
.catch(error => {
|
|
796
|
+
log.error('network send failed in fast path', { error });
|
|
797
|
+
return false;
|
|
798
|
+
});
|
|
799
|
+
const [messageId, networkOk] = await Promise.all([
|
|
800
|
+
insertPromise,
|
|
801
|
+
networkPromise,
|
|
802
|
+
]);
|
|
803
|
+
if (messageId === null) {
|
|
804
|
+
return {
|
|
805
|
+
success: false,
|
|
806
|
+
error: 'Failed to add message to database',
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
if (!networkOk) {
|
|
810
|
+
// Network failed: persist READY so the next retry doesn't
|
|
811
|
+
// re-encrypt, and let stateUpdate pick it up.
|
|
812
|
+
this.inFlightFastPath.delete(messageId);
|
|
813
|
+
await this.queries.messages.updateById(messageId, {
|
|
814
|
+
status: MessageStatus.READY,
|
|
815
|
+
encryptedMessage: sendOutput.data,
|
|
816
|
+
seeker: sendOutput.seeker,
|
|
817
|
+
whenToSend: new Date(Date.now() + this.config.messages.retryDelayMs),
|
|
818
|
+
serializedContent,
|
|
819
|
+
});
|
|
820
|
+
void this.refreshService?.stateUpdate();
|
|
821
|
+
return {
|
|
822
|
+
success: true,
|
|
823
|
+
message: {
|
|
824
|
+
...message,
|
|
825
|
+
id: messageId,
|
|
826
|
+
status: MessageStatus.READY,
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
// 4. Both succeeded. Race-check then UPDATE → SENT.
|
|
831
|
+
const latestRow = await this.queries.messages.getById(messageId);
|
|
832
|
+
if (!latestRow || latestRow.status !== MessageStatus.WAITING_SESSION) {
|
|
833
|
+
log.debug('message gone or status changed during fast-path send, skipping SENT update', { messageId, currentStatus: latestRow?.status });
|
|
834
|
+
this.inFlightFastPath.delete(messageId);
|
|
835
|
+
return {
|
|
836
|
+
success: true,
|
|
837
|
+
message: {
|
|
838
|
+
...message,
|
|
839
|
+
id: messageId,
|
|
840
|
+
status: MessageStatus.SENT,
|
|
841
|
+
},
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
await this.queries.messages.updateById(messageId, {
|
|
845
|
+
status: MessageStatus.SENT,
|
|
846
|
+
seeker: sendOutput.seeker,
|
|
847
|
+
encryptedMessage: null,
|
|
848
|
+
serializedContent: null,
|
|
849
|
+
whenToSend: null,
|
|
850
|
+
});
|
|
851
|
+
this.inFlightFastPath.delete(messageId);
|
|
852
|
+
const isControlMessage = !!(message.deleteOf || message.editOf);
|
|
853
|
+
if (!isControlMessage) {
|
|
854
|
+
try {
|
|
855
|
+
this.eventEmitter.emit(SdkEventType.MESSAGE_SENT, {
|
|
856
|
+
...message,
|
|
857
|
+
id: messageId,
|
|
858
|
+
status: MessageStatus.SENT,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
log.error('failed to emit message sent event from fast path', {
|
|
863
|
+
messageId,
|
|
864
|
+
error,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return {
|
|
869
|
+
success: true,
|
|
870
|
+
message: {
|
|
871
|
+
...message,
|
|
872
|
+
id: messageId,
|
|
873
|
+
status: MessageStatus.SENT,
|
|
874
|
+
},
|
|
875
|
+
};
|
|
876
|
+
}
|
|
679
877
|
async serializeMessage(message) {
|
|
680
878
|
const log = logger.forMethod('serializeMessage');
|
|
681
879
|
if (!message.messageId &&
|
|
@@ -853,11 +1051,17 @@ export class MessageService {
|
|
|
853
1051
|
for (const msg of pendingMessages) {
|
|
854
1052
|
if (!msg.id)
|
|
855
1053
|
continue;
|
|
856
|
-
|
|
1054
|
+
if (this.inFlightFastPath.has(msg.id))
|
|
1055
|
+
continue;
|
|
1056
|
+
const currentStatus = msg.status;
|
|
857
1057
|
let encryptedMessage = msg.encryptedMessage;
|
|
858
1058
|
let seeker = msg.seeker;
|
|
859
|
-
|
|
860
|
-
//
|
|
1059
|
+
const whenToSend = msg.whenToSend;
|
|
1060
|
+
// Happy path: WAITING_SESSION + Active session.
|
|
1061
|
+
// Encrypt → network → SENT directly, skipping the intermediate
|
|
1062
|
+
// READY SQL write. The READY block below still handles retries
|
|
1063
|
+
// (delayed sends, post-failure retries), where the encrypted
|
|
1064
|
+
// bytes need to survive a restart.
|
|
861
1065
|
if (currentStatus === MessageStatus.WAITING_SESSION &&
|
|
862
1066
|
sessionStatus === SessionStatus.Active) {
|
|
863
1067
|
let serializedContent = msg.serializedContent;
|
|
@@ -883,22 +1087,73 @@ export class MessageService {
|
|
|
883
1087
|
}
|
|
884
1088
|
encryptedMessage = sendOutput.data;
|
|
885
1089
|
seeker = sendOutput.seeker;
|
|
886
|
-
|
|
1090
|
+
// Try the network synchronously. On success, skip READY entirely
|
|
1091
|
+
// and write SENT in the background. On failure, fall back to
|
|
1092
|
+
// persisting READY so the next retry doesn't have to re-encrypt.
|
|
1093
|
+
try {
|
|
1094
|
+
await this.messageProtocol.sendMessage({
|
|
1095
|
+
seeker,
|
|
1096
|
+
ciphertext: encryptedMessage,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
log.error('network send failed for fresh message', {
|
|
1101
|
+
messageId: msg.id,
|
|
1102
|
+
error,
|
|
1103
|
+
});
|
|
1104
|
+
await this.queries.messages.updateById(msg.id, {
|
|
1105
|
+
status: MessageStatus.READY,
|
|
1106
|
+
encryptedMessage,
|
|
1107
|
+
seeker,
|
|
1108
|
+
whenToSend: new Date(Date.now() + this.config.messages.retryDelayMs),
|
|
1109
|
+
serializedContent,
|
|
1110
|
+
});
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
// Network success. Race-check: most resets only touch
|
|
1114
|
+
// READY/SENT rows so they leave us alone, but the discussion
|
|
1115
|
+
// could have been deleted while we were on the wire — bail
|
|
1116
|
+
// out if the row is gone or has moved to a non-WAITING state.
|
|
1117
|
+
const latestRow = await this.queries.messages.getById(msg.id);
|
|
1118
|
+
if (!latestRow ||
|
|
1119
|
+
latestRow.status !== MessageStatus.WAITING_SESSION) {
|
|
1120
|
+
log.debug('message gone or status changed during network send, skipping SENT update', {
|
|
1121
|
+
messageId: msg.id,
|
|
1122
|
+
currentStatus: latestRow?.status,
|
|
1123
|
+
});
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
887
1126
|
await this.queries.messages.updateById(msg.id, {
|
|
888
|
-
status: MessageStatus.
|
|
889
|
-
encryptedMessage,
|
|
1127
|
+
status: MessageStatus.SENT,
|
|
890
1128
|
seeker,
|
|
891
|
-
|
|
892
|
-
serializedContent,
|
|
1129
|
+
encryptedMessage: null,
|
|
1130
|
+
serializedContent: null,
|
|
1131
|
+
whenToSend: null,
|
|
893
1132
|
});
|
|
894
|
-
|
|
895
|
-
log.debug('message
|
|
1133
|
+
sentCount++;
|
|
1134
|
+
log.debug('message sent (skipped READY)', {
|
|
896
1135
|
messageId: msg.id,
|
|
897
|
-
status:
|
|
1136
|
+
status: MessageStatus.SENT,
|
|
898
1137
|
content: msg.content,
|
|
899
1138
|
type: msg.type,
|
|
900
1139
|
direction: msg.direction,
|
|
901
1140
|
});
|
|
1141
|
+
const isControlMessage = !!(msg.deleteOf || msg.editOf);
|
|
1142
|
+
if (!isControlMessage) {
|
|
1143
|
+
try {
|
|
1144
|
+
this.eventEmitter.emit(SdkEventType.MESSAGE_SENT, {
|
|
1145
|
+
...msg,
|
|
1146
|
+
status: MessageStatus.SENT,
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
catch (error) {
|
|
1150
|
+
log.error('failed to emit message sent event', {
|
|
1151
|
+
messageId: msg.id,
|
|
1152
|
+
error,
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
continue;
|
|
902
1157
|
}
|
|
903
1158
|
if (currentStatus === MessageStatus.READY) {
|
|
904
1159
|
const sendAt = whenToSend ?? new Date();
|
package/package.json
CHANGED