@peerbit/pubsub 1.0.1

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/src/index.ts ADDED
@@ -0,0 +1,679 @@
1
+ import type { PeerId as Libp2pPeerId } from "@libp2p/interface-peer-id";
2
+ import { logger as logFn } from "@peerbit/logger";
3
+
4
+ import { DataMessage } from "@peerbit/stream-interface";
5
+ import {
6
+ DirectStream,
7
+ DirectStreamComponents,
8
+ DirectStreamOptions,
9
+ PeerStreams,
10
+ } from "@peerbit/stream";
11
+
12
+ import { CodeError } from "@libp2p/interfaces/errors";
13
+ import {
14
+ PubSubMessage,
15
+ Subscribe,
16
+ PubSubData,
17
+ toUint8Array,
18
+ Unsubscribe,
19
+ GetSubscribers,
20
+ Subscription,
21
+ UnsubcriptionEvent,
22
+ SubscriptionEvent,
23
+ PubSub,
24
+ } from "@peerbit/pubsub-interface";
25
+ import { getPublicKeyFromPeerId, PublicSignKey } from "@peerbit/crypto";
26
+ import { CustomEvent } from "@libp2p/interfaces/events";
27
+ import { waitFor } from "@peerbit/time";
28
+ import { Connection } from "@libp2p/interface-connection";
29
+
30
+ import { equals, startsWith } from "@peerbit/uint8arrays";
31
+ import { PubSubEvents } from "@peerbit/pubsub-interface";
32
+
33
+ export const logger = logFn({ module: "direct-sub", level: "warn" });
34
+
35
+ export interface PeerStreamsInit {
36
+ id: Libp2pPeerId;
37
+ protocol: string;
38
+ }
39
+
40
+ export type DirectSubOptions = {
41
+ aggregate: boolean; // if true, we will collect topic/subscriber info for all traffic
42
+ };
43
+
44
+ export type SubscriptionData = { timestamp: bigint; data?: Uint8Array };
45
+
46
+ export type DirectSubComponents = DirectStreamComponents;
47
+
48
+ export type PeerId = Libp2pPeerId | PublicSignKey;
49
+
50
+ export class DirectSub extends DirectStream<PubSubEvents> implements PubSub {
51
+ public topics: Map<string, Map<string, SubscriptionData>>; // topic -> peers --> Uint8Array subscription metadata (the latest recieved)
52
+ public peerToTopic: Map<string, Set<string>>; // peer -> topics
53
+ public topicsToPeers: Map<string, Set<string>>; // topic -> peers
54
+ public subscriptions: Map<string, { counter: number; data?: Uint8Array }>; // topic -> subscription ids
55
+ public lastSubscriptionMessages: Map<string, Map<string, DataMessage>> =
56
+ new Map();
57
+
58
+ constructor(components: DirectSubComponents, props?: DirectStreamOptions) {
59
+ super(components, ["pubsub/0.0.0"], props);
60
+ this.subscriptions = new Map();
61
+ this.topics = new Map();
62
+ this.topicsToPeers = new Map();
63
+ this.peerToTopic = new Map();
64
+ }
65
+
66
+ stop() {
67
+ this.subscriptions.clear();
68
+ this.topics.clear();
69
+ this.peerToTopic.clear();
70
+ this.topicsToPeers.clear();
71
+ return super.stop();
72
+ }
73
+
74
+ public async onPeerReachable(publicKey: PublicSignKey) {
75
+ // Aggregate subscribers for my topics through this new peer because if we don't do this we might end up with a situtation where
76
+ // we act as a relay and relay messages for a topic, but don't forward it to this new peer because we never learned about their subscriptions
77
+ await this.requestSubscribers([...this.topics.keys()], publicKey);
78
+ return super.onPeerReachable(publicKey);
79
+ }
80
+
81
+ public async onPeerDisconnected(peerId: Libp2pPeerId, conn?: Connection) {
82
+ return super.onPeerDisconnected(peerId, conn);
83
+ }
84
+
85
+ private initializeTopic(topic: string) {
86
+ this.topics.get(topic) || this.topics.set(topic, new Map());
87
+ this.topicsToPeers.get(topic) || this.topicsToPeers.set(topic, new Set());
88
+ }
89
+
90
+ private initializePeer(publicKey: PublicSignKey) {
91
+ this.peerToTopic.get(publicKey.hashcode()) ||
92
+ this.peerToTopic.set(publicKey.hashcode(), new Set());
93
+ }
94
+
95
+ /**
96
+ * Subscribes to a given topic.
97
+ */
98
+ /**
99
+ * @param topic,
100
+ * @param data, metadata associated with the subscription, shared with peers
101
+ */
102
+ async subscribe(topic: string | string[], options?: { data?: Uint8Array }) {
103
+ if (!this.started) {
104
+ throw new Error("Pubsub has not started");
105
+ }
106
+
107
+ topic = typeof topic === "string" ? [topic] : topic;
108
+
109
+ const newTopicsForTopicData: string[] = [];
110
+ for (const t of topic) {
111
+ const prev = this.subscriptions.get(t);
112
+ if (prev) {
113
+ const difference =
114
+ !!prev.data != !!options?.data ||
115
+ (prev.data && options?.data && !equals(prev.data, options?.data));
116
+ prev.counter += 1;
117
+
118
+ if (difference) {
119
+ prev.data = options?.data;
120
+ newTopicsForTopicData.push(t);
121
+ }
122
+ } else {
123
+ this.subscriptions.set(t, {
124
+ counter: 1,
125
+ data: options?.data,
126
+ });
127
+
128
+ newTopicsForTopicData.push(t);
129
+ this.listenForSubscribers(t);
130
+ }
131
+ }
132
+
133
+ if (newTopicsForTopicData.length > 0) {
134
+ const message = new DataMessage({
135
+ data: toUint8Array(
136
+ new Subscribe({
137
+ subscriptions: newTopicsForTopicData.map(
138
+ (x) => new Subscription(x, options?.data)
139
+ ),
140
+ }).serialize()
141
+ ),
142
+ });
143
+
144
+ await this.publishMessage(
145
+ this.components.peerId,
146
+ await message.sign(this.sign)
147
+ );
148
+ }
149
+ }
150
+
151
+ /**
152
+ *
153
+ * @param topic
154
+ * @param force
155
+ * @returns true unsubscribed completely
156
+ */
157
+ async unsubscribe(
158
+ topic: string,
159
+ options?: { force: boolean; data: Uint8Array }
160
+ ) {
161
+ if (!this.started) {
162
+ throw new Error("Pubsub is not started");
163
+ }
164
+
165
+ const subscriptions = this.subscriptions.get(topic);
166
+
167
+ logger.debug(
168
+ `unsubscribe from ${topic} - am subscribed with subscriptions ${subscriptions}`
169
+ );
170
+
171
+ if (subscriptions?.counter && subscriptions?.counter >= 0) {
172
+ subscriptions.counter -= 1;
173
+ }
174
+
175
+ const peersOnTopic = this.topicsToPeers.get(topic);
176
+ if (peersOnTopic) {
177
+ for (const peer of peersOnTopic) {
178
+ this.lastSubscriptionMessages.delete(peer);
179
+ }
180
+ }
181
+ if (!subscriptions?.counter || options?.force) {
182
+ this.subscriptions.delete(topic);
183
+ this.topics.delete(topic);
184
+ this.topicsToPeers.delete(topic);
185
+
186
+ await this.publishMessage(
187
+ this.components.peerId,
188
+ await new DataMessage({
189
+ data: toUint8Array(new Unsubscribe({ topics: [topic] }).serialize()),
190
+ }).sign(this.sign)
191
+ );
192
+ return true;
193
+ }
194
+ return false;
195
+ }
196
+
197
+ getSubscribers(topic: string): Map<string, SubscriptionData> | undefined {
198
+ if (!this.started) {
199
+ throw new CodeError("not started yet", "ERR_NOT_STARTED_YET");
200
+ }
201
+
202
+ if (topic == null) {
203
+ throw new CodeError("topic is required", "ERR_NOT_VALID_TOPIC");
204
+ }
205
+
206
+ return this.topics.get(topic.toString());
207
+ }
208
+
209
+ getSubscribersWithData(
210
+ topic: string,
211
+ data: Uint8Array,
212
+ options?: { prefix: boolean }
213
+ ): string[] | undefined {
214
+ const map = this.topics.get(topic);
215
+ if (map) {
216
+ const results: string[] = [];
217
+ for (const [peer, info] of map.entries()) {
218
+ if (!info.data) {
219
+ continue;
220
+ }
221
+ if (options?.prefix) {
222
+ if (!startsWith(info.data, data)) {
223
+ continue;
224
+ }
225
+ } else {
226
+ if (!equals(info.data, data)) {
227
+ continue;
228
+ }
229
+ }
230
+ results.push(peer);
231
+ }
232
+ return results;
233
+ }
234
+ return;
235
+ }
236
+
237
+ listenForSubscribers(topic: string) {
238
+ this.initializeTopic(topic);
239
+ }
240
+
241
+ async requestSubscribers(
242
+ topic: string | string[],
243
+ from?: PublicSignKey
244
+ ): Promise<void> {
245
+ if (!this.started) {
246
+ throw new CodeError("not started yet", "ERR_NOT_STARTED_YET");
247
+ }
248
+
249
+ if (topic == null) {
250
+ throw new CodeError("topic is required", "ERR_NOT_VALID_TOPIC");
251
+ }
252
+
253
+ if (topic.length === 0) {
254
+ return;
255
+ }
256
+
257
+ const topics = typeof topic === "string" ? [topic] : topic;
258
+ for (const topic of topics) {
259
+ this.listenForSubscribers(topic);
260
+ }
261
+
262
+ return this.publishMessage(
263
+ this.components.peerId,
264
+ await new DataMessage({
265
+ to: from ? [from.hashcode()] : [],
266
+ data: toUint8Array(new GetSubscribers({ topics }).serialize()),
267
+ }).sign(this.sign)
268
+ );
269
+ }
270
+
271
+ getPeersWithTopics(topics: string[], otherPeers?: string[]): Set<string> {
272
+ const peers: Set<string> = otherPeers ? new Set(otherPeers) : new Set();
273
+ if (topics?.length) {
274
+ for (const topic of topics) {
275
+ const peersOnTopic = this.topicsToPeers.get(topic.toString());
276
+ if (peersOnTopic) {
277
+ peersOnTopic.forEach((peer) => {
278
+ peers.add(peer);
279
+ });
280
+ }
281
+ }
282
+ }
283
+ return peers;
284
+ }
285
+
286
+ /* getStreamsWithTopics(topics: string[], otherPeers?: string[]): PeerStreams[] {
287
+ const peers = this.getNeighboursWithTopics(topics, otherPeers);
288
+ return [...this.peers.values()].filter((s) =>
289
+ peers.has(s.publicKey.hashcode())
290
+ );
291
+ } */
292
+
293
+ async publish(
294
+ data: Uint8Array,
295
+ options:
296
+ | {
297
+ topics?: string[];
298
+ to?: (string | PeerId)[];
299
+ strict?: false;
300
+ }
301
+ | {
302
+ topics: string[];
303
+ to: (string | PeerId)[];
304
+ strict: true;
305
+ }
306
+ ): Promise<Uint8Array> {
307
+ const topics =
308
+ (options as { topics: string[] }).topics?.map((x) => x.toString()) || [];
309
+ const tos =
310
+ options?.to?.map((x) =>
311
+ x instanceof PublicSignKey
312
+ ? x.hashcode()
313
+ : typeof x === "string"
314
+ ? x
315
+ : getPublicKeyFromPeerId(x).hashcode()
316
+ ) || [];
317
+ // Embedd topic info before the data so that peers/relays can also use topic info to route messages efficiently
318
+ const message = new PubSubData({
319
+ topics: topics.map((x) => x.toString()),
320
+ data,
321
+ strict: options.strict,
322
+ });
323
+ const bytes = message.serialize();
324
+ return super.publish(
325
+ bytes instanceof Uint8Array ? bytes : bytes.subarray(),
326
+ { to: options?.strict ? tos : this.getPeersWithTopics(topics, tos) }
327
+ );
328
+ }
329
+
330
+ private deletePeerFromTopic(topic: string, publicKeyHash: string) {
331
+ const peers = this.topics.get(topic);
332
+ let change: SubscriptionData | undefined = undefined;
333
+ if (peers) {
334
+ change = peers.get(publicKeyHash);
335
+ }
336
+
337
+ this.topics.get(topic)?.delete(publicKeyHash);
338
+
339
+ this.peerToTopic.get(publicKeyHash)?.delete(topic);
340
+ if (!this.peerToTopic.get(publicKeyHash)?.size) {
341
+ this.peerToTopic.delete(publicKeyHash);
342
+ }
343
+
344
+ this.topicsToPeers.get(topic)?.delete(publicKeyHash);
345
+
346
+ return change;
347
+ }
348
+ public onPeerUnreachable(publicKey: PublicSignKey) {
349
+ super.onPeerUnreachable(publicKey);
350
+
351
+ const publicKeyHash = publicKey.hashcode();
352
+ const peerTopics = this.peerToTopic.get(publicKeyHash);
353
+
354
+ const changed: Subscription[] = [];
355
+ if (peerTopics) {
356
+ for (const topic of peerTopics) {
357
+ const change = this.deletePeerFromTopic(topic, publicKeyHash);
358
+ if (change) {
359
+ changed.push(new Subscription(topic, change.data));
360
+ }
361
+ }
362
+ }
363
+ this.lastSubscriptionMessages.delete(publicKeyHash);
364
+
365
+ if (changed.length > 0) {
366
+ this.dispatchEvent(
367
+ new CustomEvent<UnsubcriptionEvent>("unsubscribe", {
368
+ detail: { from: publicKey, unsubscriptions: changed },
369
+ })
370
+ );
371
+ }
372
+ }
373
+
374
+ private subscriptionMessageIsLatest(
375
+ message: DataMessage,
376
+ pubsubMessage: Subscribe | Unsubscribe
377
+ ) {
378
+ const subscriber = message.signatures.signatures[0].publicKey!;
379
+ const subscriberKey = subscriber.hashcode(); // Assume first signature is the one who is signing
380
+
381
+ for (const topic of pubsubMessage.topics) {
382
+ const lastTimestamp = this.lastSubscriptionMessages
383
+ .get(subscriberKey)
384
+ ?.get(topic)?.header.timetamp;
385
+ if (lastTimestamp != null && lastTimestamp > message.header.timetamp) {
386
+ return false; // message is old
387
+ }
388
+ }
389
+
390
+ for (const topic of pubsubMessage.topics) {
391
+ if (!this.lastSubscriptionMessages.has(subscriberKey)) {
392
+ this.lastSubscriptionMessages.set(subscriberKey, new Map());
393
+ }
394
+ this.lastSubscriptionMessages.get(subscriberKey)?.set(topic, message);
395
+ }
396
+ return true;
397
+ }
398
+
399
+ async onDataMessage(
400
+ from: Libp2pPeerId,
401
+ stream: PeerStreams,
402
+ message: DataMessage
403
+ ) {
404
+ const pubsubMessage = PubSubMessage.deserialize(message.data);
405
+ if (pubsubMessage instanceof PubSubData) {
406
+ /**
407
+ * See if we know more subscribers of the message topics. If so, add aditional end recievers of the message
408
+ */
409
+ let verified: boolean | undefined = undefined;
410
+
411
+ const isFromSelf = this.components.peerId.equals(from);
412
+ if (!isFromSelf || this.emitSelf) {
413
+ let isForMe: boolean;
414
+ if (pubsubMessage.strict) {
415
+ isForMe =
416
+ !!pubsubMessage.topics.find((topic) =>
417
+ this.subscriptions.has(topic)
418
+ ) && !!message.to.find((x) => this.publicKeyHash === x);
419
+ } else {
420
+ isForMe =
421
+ !!pubsubMessage.topics.find((topic) =>
422
+ this.subscriptions.has(topic)
423
+ ) ||
424
+ (pubsubMessage.topics.length === 0 &&
425
+ !!message.to.find((x) => this.publicKeyHash === x));
426
+ }
427
+ if (isForMe) {
428
+ if (verified === undefined) {
429
+ verified = await message.verify(
430
+ this.signaturePolicy === "StictSign" ? true : false
431
+ );
432
+ }
433
+ if (!verified) {
434
+ logger.warn("Recieved message that did not verify PubSubData");
435
+ return false;
436
+ }
437
+ this.dispatchEvent(
438
+ new CustomEvent("data", {
439
+ detail: { data: pubsubMessage, message },
440
+ })
441
+ );
442
+ }
443
+ }
444
+
445
+ // Forward
446
+ if (!pubsubMessage.strict) {
447
+ const newTos = this.getPeersWithTopics(
448
+ pubsubMessage.topics,
449
+ message.to
450
+ );
451
+ newTos.delete(this.publicKeyHash);
452
+ message.to = [...newTos];
453
+ }
454
+
455
+ // Only relay if we got additional recievers
456
+ // or we are NOT subscribing ourselves (if we are not subscribing ourselves we are)
457
+ // If we are not subscribing ourselves, then we don't have enough information to "stop" message propagation here
458
+ if (
459
+ message.to.length > 0 ||
460
+ !pubsubMessage.topics.find((topic) => this.topics.has(topic))
461
+ ) {
462
+ await this.relayMessage(from, message);
463
+ }
464
+ } else if (pubsubMessage instanceof Subscribe) {
465
+ if (!(await message.verify(true))) {
466
+ logger.warn("Recieved message that did not verify Subscribe");
467
+ return false;
468
+ }
469
+
470
+ if (message.signatures.signatures.length === 0) {
471
+ logger.warn("Recieved subscription message with no signers");
472
+ return false;
473
+ }
474
+
475
+ if (pubsubMessage.subscriptions.length === 0) {
476
+ logger.info("Recieved subscription message with no topics");
477
+ return false;
478
+ }
479
+
480
+ if (!this.subscriptionMessageIsLatest(message, pubsubMessage)) {
481
+ logger.trace("Recieved old subscription message");
482
+ return false;
483
+ }
484
+
485
+ const subscriber = message.signatures.signatures[0].publicKey!;
486
+ const subscriberKey = subscriber.hashcode(); // Assume first signature is the one who is signing
487
+
488
+ this.initializePeer(subscriber);
489
+
490
+ const changed: Subscription[] = [];
491
+ pubsubMessage.subscriptions.forEach((subscription) => {
492
+ const peers = this.topics.get(subscription.topic);
493
+ if (peers == null) {
494
+ return;
495
+ }
496
+
497
+ // if no subscription data, or new subscription has data (and is newer) then overwrite it.
498
+ // subscription where data is undefined is not intended to replace existing data
499
+ const existingSubscription = peers.get(subscriberKey);
500
+
501
+ if (
502
+ !existingSubscription ||
503
+ (existingSubscription.timestamp < message.header.timetamp &&
504
+ subscription.data)
505
+ ) {
506
+ peers.set(subscriberKey, {
507
+ timestamp: message.header.timetamp, // TODO update timestamps on all messages?
508
+ data: subscription.data,
509
+ });
510
+ if (
511
+ !existingSubscription?.data ||
512
+ !equals(existingSubscription.data, subscription.data)
513
+ ) {
514
+ changed.push(subscription);
515
+ }
516
+ }
517
+
518
+ this.topicsToPeers.get(subscription.topic)?.add(subscriberKey);
519
+ this.peerToTopic.get(subscriberKey)?.add(subscription.topic);
520
+ });
521
+ if (changed.length > 0) {
522
+ const subscriptionEvent: SubscriptionEvent = {
523
+ from: subscriber,
524
+ subscriptions: changed,
525
+ };
526
+
527
+ this.dispatchEvent(
528
+ new CustomEvent<SubscriptionEvent>("subscribe", {
529
+ detail: subscriptionEvent,
530
+ })
531
+ );
532
+ }
533
+
534
+ // Forward
535
+ await this.relayMessage(from, message);
536
+ } else if (pubsubMessage instanceof Unsubscribe) {
537
+ if (!(await message.verify(true))) {
538
+ logger.warn("Recieved message that did not verify Unsubscribe");
539
+ return false;
540
+ }
541
+
542
+ if (message.signatures.signatures.length === 0) {
543
+ logger.warn("Recieved subscription message with no signers");
544
+ return false;
545
+ }
546
+
547
+ if (!this.subscriptionMessageIsLatest(message, pubsubMessage)) {
548
+ logger.trace("Recieved old subscription message");
549
+ return false;
550
+ }
551
+
552
+ const changed: Subscription[] = [];
553
+ const subscriber = message.signatures.signatures[0].publicKey!;
554
+ const subscriberKey = subscriber.hashcode(); // Assume first signature is the one who is signing
555
+
556
+ for (const unsubscription of pubsubMessage.unsubscriptions) {
557
+ const change = this.deletePeerFromTopic(
558
+ unsubscription.topic,
559
+ subscriberKey
560
+ );
561
+ if (change) {
562
+ changed.push(new Subscription(unsubscription.topic, change.data));
563
+ }
564
+ }
565
+
566
+ if (changed.length > 0) {
567
+ this.dispatchEvent(
568
+ new CustomEvent<UnsubcriptionEvent>("unsubscribe", {
569
+ detail: { from: subscriber, unsubscriptions: changed },
570
+ })
571
+ );
572
+ }
573
+
574
+ // Forward
575
+ await this.relayMessage(from, message);
576
+ } else if (pubsubMessage instanceof GetSubscribers) {
577
+ if (!(await message.verify(true))) {
578
+ logger.warn("Recieved message that did not verify Unsubscribe");
579
+ return false;
580
+ }
581
+
582
+ const subscriptionsToSend: Subscription[] = [];
583
+ for (const topic of pubsubMessage.topics) {
584
+ const subscription = this.subscriptions.get(topic);
585
+ if (subscription) {
586
+ subscriptionsToSend.push(new Subscription(topic, subscription.data));
587
+ }
588
+ }
589
+
590
+ if (subscriptionsToSend.length > 0) {
591
+ // respond
592
+ if (!stream.isWritable) {
593
+ try {
594
+ await waitFor(() => stream.isWritable);
595
+ } catch (error) {
596
+ logger.warn(
597
+ `Failed to respond to GetSubscribers request to ${from.toString()} stream is not writable`
598
+ );
599
+ return false;
600
+ }
601
+ }
602
+ this.publishMessage(
603
+ this.components.peerId,
604
+ await new DataMessage({
605
+ data: toUint8Array(
606
+ new Subscribe({
607
+ subscriptions: subscriptionsToSend,
608
+ }).serialize()
609
+ ),
610
+ }).sign(this.sign),
611
+ [stream]
612
+ ); // send back to same stream
613
+ }
614
+
615
+ // Forward
616
+ await this.relayMessage(from, message);
617
+ }
618
+ return true;
619
+ }
620
+ }
621
+
622
+ export const waitForSubscribers = async (
623
+ libp2p: { services: { pubsub: DirectSub } },
624
+ peersToWait:
625
+ | PeerId
626
+ | PeerId[]
627
+ | { peerId: Libp2pPeerId }
628
+ | { peerId: Libp2pPeerId }[]
629
+ | string
630
+ | string[],
631
+ topic: string
632
+ ) => {
633
+ const peersToWaitArr = Array.isArray(peersToWait)
634
+ ? peersToWait
635
+ : [peersToWait];
636
+
637
+ const peerIdsToWait: string[] = peersToWaitArr.map((peer) => {
638
+ if (typeof peer === "string") {
639
+ return peer;
640
+ }
641
+ const id: PublicSignKey | Libp2pPeerId = peer["peerId"] || peer;
642
+ if (typeof id === "string") {
643
+ return id;
644
+ }
645
+ return id instanceof PublicSignKey
646
+ ? id.hashcode()
647
+ : getPublicKeyFromPeerId(id).hashcode();
648
+ });
649
+
650
+ await libp2p.services.pubsub.requestSubscribers(topic);
651
+ return new Promise<void>((resolve, reject) => {
652
+ let counter = 0;
653
+ const interval = setInterval(async () => {
654
+ counter += 1;
655
+ if (counter > 100) {
656
+ clearInterval(interval);
657
+ reject(
658
+ new Error("Failed to find expected subscribers for topic: " + topic)
659
+ );
660
+ }
661
+ try {
662
+ const peers = libp2p.services.pubsub.getSubscribers(topic);
663
+ const hasAllPeers =
664
+ peerIdsToWait
665
+ .map((e) => peers && peers.has(e))
666
+ .filter((e) => e === false).length === 0;
667
+
668
+ // FIXME: Does not fail on timeout, not easily fixable
669
+ if (hasAllPeers) {
670
+ clearInterval(interval);
671
+ resolve();
672
+ }
673
+ } catch (e) {
674
+ clearInterval(interval);
675
+ reject(e);
676
+ }
677
+ }, 200);
678
+ });
679
+ };