@johnhenry/browsermesh-transport 0.1.1 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/browsermesh-transport",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "WebSocket, WebRTC, WebTransport, and relay adapters for BrowserMesh",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -22,7 +22,7 @@
22
22
  "@johnhenry/browsermesh-primitives": ">=0.0.1 <1.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "node-datachannel": "^0.33.2"
25
+ "node-datachannel": "^0.33.4"
26
26
  },
27
27
  "scripts": {
28
28
  "test": "node --import ./test/_setup-globals.mjs --test test/*.test.mjs",
package/src/webrtc.mjs CHANGED
@@ -874,18 +874,32 @@ export class WebRTCPeerConnection {
874
874
  // ---------------------------------------------------------------------------
875
875
 
876
876
  /**
877
- * Manages multiple WebRTC peer connections indexed by remotePodId.
877
+ * Connection identifier used when a caller doesn't ask for a specific one.
878
+ * `connectToPeer(remotePodId)` (no `connectionId`) always resolves to this
879
+ * slot, so every pre-existing single-connection-per-peer call site keeps
880
+ * behaving exactly as before -- multiple independent connections to the
881
+ * same peer are strictly additive, opt-in via an explicit `connectionId`.
882
+ */
883
+ export const DEFAULT_CONNECTION_ID = 'default'
884
+
885
+ /**
886
+ * Manages multiple WebRTC peer connections indexed by remotePodId, and (see
887
+ * issue #116) optionally by a caller-chosen `connectionId` within a peer --
888
+ * e.g. one connection per traffic class, each paying its own ICE/STUN/DTLS
889
+ * negotiation cost independently. `connectionId` defaults to
890
+ * `DEFAULT_CONNECTION_ID`, so callers who never pass one see the original
891
+ * one-connection-per-peer behavior unchanged.
878
892
  * Thin orchestration layer — signaling is left to the caller.
879
893
  */
880
894
  export class WebRTCMeshManager {
881
895
  #localPodId
882
896
  #iceServers
883
- #connections = new Map() // remotePodId -> WebRTCPeerConnection
897
+ #connections = new Map() // remotePodId -> Map<connectionId, WebRTCPeerConnection>
884
898
  #onLog
885
899
  #messageCbs = []
886
900
  #reconnectOfferCbs = []
887
- #reconnectAttempts = new Map() // remotePodId -> count
888
- #reconnectTimers = new Map() // remotePodId -> timer handle
901
+ #reconnectAttempts = new Map() // "remotePodId connectionId" -> count
902
+ #reconnectTimers = new Map() // "remotePodId connectionId" -> timer handle
889
903
  #maxReconnectAttempts
890
904
  #reconnectBaseDelayMs
891
905
  #disconnectedGraceMs
@@ -918,13 +932,17 @@ export class WebRTCMeshManager {
918
932
  /** Local pod identifier. */
919
933
  get localPodId() { return this.#localPodId }
920
934
 
921
- /** Number of tracked connections. */
922
- get connectionCount() { return this.#connections.size }
935
+ /** Number of tracked connections, across all peers and connectionIds. */
936
+ get connectionCount() {
937
+ let count = 0
938
+ for (const byConnectionId of this.#connections.values()) count += byConnectionId.size
939
+ return count
940
+ }
923
941
 
924
942
  /**
925
943
  * Register a global message listener that fires for all connections.
926
944
  *
927
- * @param {Function} cb - Called with (data, remotePodId)
945
+ * @param {Function} cb - Called with (data, remotePodId, connectionId)
928
946
  */
929
947
  onMessage(cb) { this.#messageCbs.push(cb) }
930
948
 
@@ -934,20 +952,40 @@ export class WebRTCMeshManager {
934
952
  * this offer through the same external signaling channel used for the
935
953
  * original connection.
936
954
  *
937
- * @param {Function} cb - Called with (offer: {type, sdp}, remotePodId: string)
955
+ * @param {Function} cb - Called with (offer: {type, sdp}, remotePodId: string, connectionId: string)
938
956
  */
939
957
  onReconnectOffer(cb) { this.#reconnectOfferCbs.push(cb) }
940
958
 
959
+ #reconnectKey(remotePodId, connectionId) { return `${remotePodId} ${connectionId}` }
960
+
941
961
  /**
942
962
  * Create or return an existing WebRTCPeerConnection for a remote pod.
943
- * Returns the same instance on duplicate calls with the same remotePodId.
963
+ * Returns the same instance on duplicate calls with the same remotePodId
964
+ * *and* connectionId.
965
+ *
966
+ * Passing a `connectionId` other than the default opens a fully
967
+ * independent `RTCPeerConnection` to the same peer -- its own ICE/STUN/
968
+ * DTLS negotiation, its own DataChannel, its own reconnect backoff. See
969
+ * issue #116: this is the real cost of the feature, not a limitation of
970
+ * this method -- there is no way to get a second logical channel to a
971
+ * peer more cheaply than that without reusing an existing connection (see
972
+ * `WebRTCPeerConnection`'s single-DataChannel model / issue #115).
944
973
  *
945
974
  * @param {string} remotePodId
975
+ * @param {object} [opts]
976
+ * @param {string} [opts.connectionId] - Defaults to `DEFAULT_CONNECTION_ID`.
977
+ * Independent connectionIds to the same remotePodId are independent
978
+ * `RTCPeerConnection`s.
946
979
  * @returns {Promise<WebRTCPeerConnection>}
947
980
  */
948
- async connectToPeer(remotePodId) {
949
- if (this.#connections.has(remotePodId)) {
950
- return this.#connections.get(remotePodId)
981
+ async connectToPeer(remotePodId, { connectionId = DEFAULT_CONNECTION_ID } = {}) {
982
+ let byConnectionId = this.#connections.get(remotePodId)
983
+ if (byConnectionId?.has(connectionId)) {
984
+ return byConnectionId.get(connectionId)
985
+ }
986
+ if (!byConnectionId) {
987
+ byConnectionId = new Map()
988
+ this.#connections.set(remotePodId, byConnectionId)
951
989
  }
952
990
  const conn = new WebRTCPeerConnection({
953
991
  localPodId: this.#localPodId,
@@ -959,21 +997,25 @@ export class WebRTCMeshManager {
959
997
  // Forward messages to manager-level listeners
960
998
  conn.onMessage((data) => {
961
999
  for (const cb of this.#messageCbs) {
962
- try { cb(data, remotePodId) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
1000
+ try { cb(data, remotePodId, connectionId) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
963
1001
  }
964
1002
  })
965
1003
  // Auto-remove on close
966
1004
  conn.onClose(() => {
967
- this.#connections.delete(remotePodId)
968
- this.#clearReconnectState(remotePodId)
1005
+ const stillTracked = this.#connections.get(remotePodId)
1006
+ if (stillTracked) {
1007
+ stillTracked.delete(connectionId)
1008
+ if (stillTracked.size === 0) this.#connections.delete(remotePodId)
1009
+ }
1010
+ this.#clearReconnectState(remotePodId, connectionId)
969
1011
  })
970
1012
  // Reset backoff once the connection actually recovers
971
1013
  conn.onStateChange((state) => {
972
- if (state === 'connected') this.#clearReconnectState(remotePodId)
1014
+ if (state === 'connected') this.#clearReconnectState(remotePodId, connectionId)
973
1015
  })
974
1016
  // Auto-retry with exponential backoff on failure/disconnect
975
- conn.onError(() => this.#scheduleReconnect(remotePodId, conn))
976
- this.#connections.set(remotePodId, conn)
1017
+ conn.onError(() => this.#scheduleReconnect(remotePodId, connectionId, conn))
1018
+ byConnectionId.set(connectionId, conn)
977
1019
  return conn
978
1020
  }
979
1021
 
@@ -984,108 +1026,135 @@ export class WebRTCMeshManager {
984
1026
  * @param {object} [opts]
985
1027
  * @param {boolean} [opts.force=false] - Renegotiate even if the connection
986
1028
  * looks healthy. Without it a healthy connection is left alone.
1029
+ * @param {string} [opts.connectionId] - Defaults to `DEFAULT_CONNECTION_ID`.
987
1030
  * @returns {Promise<{type: 'offer', sdp: string, renegotiation: true}|null>}
988
1031
  * null if there is no such connection, or nothing to repair.
989
1032
  */
990
- async reconnectPeer(remotePodId, { force = false } = {}) {
991
- const conn = this.#connections.get(remotePodId)
1033
+ async reconnectPeer(remotePodId, { force = false, connectionId = DEFAULT_CONNECTION_ID } = {}) {
1034
+ const conn = this.#connections.get(remotePodId)?.get(connectionId)
992
1035
  if (!conn) return null
993
1036
  const offer = await conn.reconnect({ force })
994
1037
  if (!offer) return null
995
- this.#notifyReconnectOffer(offer, remotePodId)
1038
+ this.#notifyReconnectOffer(offer, remotePodId, connectionId)
996
1039
  return offer
997
1040
  }
998
1041
 
999
- #clearReconnectState(remotePodId) {
1000
- this.#reconnectAttempts.delete(remotePodId)
1001
- const timer = this.#reconnectTimers.get(remotePodId)
1042
+ #clearReconnectState(remotePodId, connectionId) {
1043
+ const key = this.#reconnectKey(remotePodId, connectionId)
1044
+ this.#reconnectAttempts.delete(key)
1045
+ const timer = this.#reconnectTimers.get(key)
1002
1046
  if (timer) {
1003
1047
  clearTimeout(timer)
1004
- this.#reconnectTimers.delete(remotePodId)
1048
+ this.#reconnectTimers.delete(key)
1005
1049
  }
1006
1050
  }
1007
1051
 
1008
- #notifyReconnectOffer(offer, remotePodId) {
1052
+ #notifyReconnectOffer(offer, remotePodId, connectionId) {
1009
1053
  for (const cb of this.#reconnectOfferCbs) {
1010
- try { cb(offer, remotePodId) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
1054
+ try { cb(offer, remotePodId, connectionId) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
1011
1055
  }
1012
1056
  }
1013
1057
 
1014
- #scheduleReconnect(remotePodId, conn) {
1015
- if (this.#reconnectTimers.has(remotePodId)) return // already scheduled
1016
- const attempts = this.#reconnectAttempts.get(remotePodId) || 0
1058
+ #scheduleReconnect(remotePodId, connectionId, conn) {
1059
+ const key = this.#reconnectKey(remotePodId, connectionId)
1060
+ if (this.#reconnectTimers.has(key)) return // already scheduled
1061
+ const attempts = this.#reconnectAttempts.get(key) || 0
1017
1062
  if (attempts >= this.#maxReconnectAttempts) {
1018
- if (this.#onLog) this.#onLog(`Giving up reconnecting to ${remotePodId} after ${attempts} attempts`)
1063
+ if (this.#onLog) this.#onLog(`Giving up reconnecting to ${remotePodId} (${connectionId}) after ${attempts} attempts`)
1019
1064
  return
1020
1065
  }
1021
1066
  const delay = this.#reconnectBaseDelayMs * (2 ** attempts)
1022
- this.#reconnectAttempts.set(remotePodId, attempts + 1)
1067
+ this.#reconnectAttempts.set(key, attempts + 1)
1023
1068
  const timer = setTimeout(async () => {
1024
- this.#reconnectTimers.delete(remotePodId)
1025
- if (!this.#connections.has(remotePodId)) return // closed/removed meanwhile
1069
+ this.#reconnectTimers.delete(key)
1070
+ if (!this.#connections.get(remotePodId)?.has(connectionId)) return // closed/removed meanwhile
1026
1071
  try {
1027
1072
  const offer = await conn.reconnect()
1028
1073
  if (offer) {
1029
- this.#notifyReconnectOffer(offer, remotePodId)
1074
+ this.#notifyReconnectOffer(offer, remotePodId, connectionId)
1030
1075
  } else {
1031
1076
  // Nothing was wrong with the connection after all -- the error that
1032
1077
  // scheduled this attempt was spurious, or it healed while we waited.
1033
1078
  // Refund the backoff rather than counting it against the peer.
1034
- this.#clearReconnectState(remotePodId)
1079
+ this.#clearReconnectState(remotePodId, connectionId)
1035
1080
  }
1036
1081
  } catch (e) { silentCatch('clawser-mesh-webrtc', 'reconnect-attempt', e) }
1037
1082
  }, delay)
1038
- this.#reconnectTimers.set(remotePodId, timer)
1083
+ this.#reconnectTimers.set(key, timer)
1039
1084
  }
1040
1085
 
1041
1086
  /**
1042
- * Get an existing connection by remotePodId.
1087
+ * Get an existing connection by remotePodId (and, optionally, connectionId).
1043
1088
  *
1044
1089
  * @param {string} remotePodId
1090
+ * @param {string} [connectionId] - Defaults to `DEFAULT_CONNECTION_ID`.
1045
1091
  * @returns {WebRTCPeerConnection|null}
1046
1092
  */
1047
- getConnection(remotePodId) {
1048
- return this.#connections.get(remotePodId) || null
1093
+ getConnection(remotePodId, connectionId = DEFAULT_CONNECTION_ID) {
1094
+ return this.#connections.get(remotePodId)?.get(connectionId) || null
1095
+ }
1096
+
1097
+ /**
1098
+ * List every connectionId tracked for a peer, alongside its connection.
1099
+ * Empty array if the peer has no tracked connections at all.
1100
+ *
1101
+ * @param {string} remotePodId
1102
+ * @returns {Array<{connectionId: string, connection: WebRTCPeerConnection}>}
1103
+ */
1104
+ getConnectionsFor(remotePodId) {
1105
+ const byConnectionId = this.#connections.get(remotePodId)
1106
+ if (!byConnectionId) return []
1107
+ return [...byConnectionId.entries()].map(([connectionId, connection]) => ({ connectionId, connection }))
1049
1108
  }
1050
1109
 
1051
1110
  /**
1052
1111
  * Check whether a connection to remotePodId exists.
1053
1112
  *
1054
1113
  * @param {string} remotePodId
1114
+ * @param {string} [connectionId] - If given, checks that exact connection.
1115
+ * If omitted, checks whether *any* connection to remotePodId exists
1116
+ * (matches the pre-#116 behavior for single-connection-per-peer callers).
1055
1117
  * @returns {boolean}
1056
1118
  */
1057
- hasConnection(remotePodId) {
1058
- return this.#connections.has(remotePodId)
1119
+ hasConnection(remotePodId, connectionId) {
1120
+ const byConnectionId = this.#connections.get(remotePodId)
1121
+ if (!byConnectionId) return false
1122
+ return connectionId === undefined ? byConnectionId.size > 0 : byConnectionId.has(connectionId)
1059
1123
  }
1060
1124
 
1061
1125
  /**
1062
1126
  * List all tracked connections with their current state.
1063
1127
  *
1064
- * @returns {Array<{remotePodId: string, state: string}>}
1128
+ * @returns {Array<{remotePodId: string, connectionId: string, state: string}>}
1065
1129
  */
1066
1130
  listConnections() {
1067
- return [...this.#connections.entries()].map(([remotePodId, conn]) => ({
1068
- remotePodId,
1069
- state: conn.state,
1070
- }))
1131
+ const results = []
1132
+ for (const [remotePodId, byConnectionId] of this.#connections.entries()) {
1133
+ for (const [connectionId, conn] of byConnectionId.entries()) {
1134
+ results.push({ remotePodId, connectionId, state: conn.state })
1135
+ }
1136
+ }
1137
+ return results
1071
1138
  }
1072
1139
 
1073
1140
  /**
1074
- * Query `getConnectionStats()` on every tracked connection. A single
1075
- * connection's stats query failing (e.g. mid-teardown) doesn't abort
1076
- * the rest — its entry carries `error` instead. Result is cached on
1077
- * `lastStats` for synchronous readers (e.g. MeshInspector.snapshot(),
1078
- * which can't await this method).
1141
+ * Query `getConnectionStats()` on every tracked connection (every
1142
+ * connectionId, for every peer). A single connection's stats query
1143
+ * failing (e.g. mid-teardown) doesn't abort the rest — its entry carries
1144
+ * `error` instead. Result is cached on `lastStats` for synchronous readers
1145
+ * (e.g. MeshInspector.snapshot(), which can't await this method).
1079
1146
  *
1080
1147
  * @returns {Promise<Array<object>>}
1081
1148
  */
1082
1149
  async getAllConnectionStats() {
1083
1150
  const results = []
1084
- for (const [remotePodId, conn] of this.#connections.entries()) {
1085
- try {
1086
- results.push(await conn.getConnectionStats())
1087
- } catch (err) {
1088
- results.push({ remotePodId, state: conn.state, error: err?.message || String(err) })
1151
+ for (const [remotePodId, byConnectionId] of this.#connections.entries()) {
1152
+ for (const [connectionId, conn] of byConnectionId.entries()) {
1153
+ try {
1154
+ results.push({ ...(await conn.getConnectionStats()), connectionId })
1155
+ } catch (err) {
1156
+ results.push({ remotePodId, connectionId, state: conn.state, error: err?.message || String(err) })
1157
+ }
1089
1158
  }
1090
1159
  }
1091
1160
  this.#lastStats = results
@@ -1100,15 +1169,28 @@ export class WebRTCMeshManager {
1100
1169
  get lastStats() { return this.#lastStats }
1101
1170
 
1102
1171
  /**
1103
- * Broadcast data to all connected peers.
1172
+ * Broadcast data to all connected peers, once per peer -- even if a peer
1173
+ * has multiple tracked connections (issue #116), it gets the message once,
1174
+ * over its default connection if that one is open, otherwise over
1175
+ * whichever of its other connections is. Callers that need to reach every
1176
+ * connection of every peer explicitly (rather than one message per peer)
1177
+ * should iterate `getConnectionsFor()`/`listConnections()` themselves.
1104
1178
  *
1105
1179
  * @param {string|object} data
1106
1180
  * @returns {number} Number of peers the message was sent to
1107
1181
  */
1108
1182
  broadcast(data) {
1109
1183
  let sent = 0
1110
- for (const conn of this.#connections.values()) {
1111
- if (conn.isOpen) {
1184
+ for (const byConnectionId of this.#connections.values()) {
1185
+ const defaultConn = byConnectionId.get(DEFAULT_CONNECTION_ID)
1186
+ // A Map entry is truthy regardless of isOpen, so this cannot be
1187
+ // `defaultConn || [...].find(isOpen)` -- that would always short-
1188
+ // circuit on the default entry existing at all, open or not, and
1189
+ // never fall back.
1190
+ const conn = (defaultConn && defaultConn.isOpen)
1191
+ ? defaultConn
1192
+ : [...byConnectionId.values()].find((c) => c.isOpen)
1193
+ if (conn && conn.isOpen) {
1112
1194
  try {
1113
1195
  conn.send(data)
1114
1196
  sent++
@@ -1122,22 +1204,34 @@ export class WebRTCMeshManager {
1122
1204
  * Close a specific peer connection.
1123
1205
  *
1124
1206
  * @param {string} remotePodId
1125
- * @returns {boolean} True if a connection was found and closed
1207
+ * @param {string} [connectionId] - If given, closes just that connection.
1208
+ * If omitted, closes *every* tracked connection to remotePodId (matches
1209
+ * the pre-#116 behavior for single-connection-per-peer callers).
1210
+ * @returns {boolean} True if at least one connection was found and closed
1126
1211
  */
1127
- closePeer(remotePodId) {
1128
- const conn = this.#connections.get(remotePodId)
1129
- if (!conn) return false
1130
- conn.close()
1131
- this.#connections.delete(remotePodId)
1212
+ closePeer(remotePodId, connectionId) {
1213
+ const byConnectionId = this.#connections.get(remotePodId)
1214
+ if (!byConnectionId || byConnectionId.size === 0) return false
1215
+ if (connectionId !== undefined) {
1216
+ const conn = byConnectionId.get(connectionId)
1217
+ if (!conn) return false
1218
+ conn.close()
1219
+ // conn.onClose() (wired in connectToPeer) removes it from #connections.
1220
+ return true
1221
+ }
1222
+ for (const conn of [...byConnectionId.values()]) conn.close()
1132
1223
  return true
1133
1224
  }
1134
1225
 
1135
1226
  /**
1136
- * Close all peer connections and clear internal state.
1227
+ * Close all peer connections (every connectionId, for every peer) and
1228
+ * clear internal state.
1137
1229
  */
1138
1230
  closeAll() {
1139
- for (const conn of this.#connections.values()) {
1140
- try { conn.close() } catch (e) { silentCatch('clawser-mesh-webrtc', 'conn.close', e) }
1231
+ for (const byConnectionId of this.#connections.values()) {
1232
+ for (const conn of byConnectionId.values()) {
1233
+ try { conn.close() } catch (e) { silentCatch('clawser-mesh-webrtc', 'conn.close', e) }
1234
+ }
1141
1235
  }
1142
1236
  this.#connections.clear()
1143
1237
  }
package/src/websocket.mjs CHANGED
@@ -492,7 +492,7 @@ export class WebRTCTransport {
492
492
  });
493
493
 
494
494
  // Set up signaler listeners for remote ICE candidates
495
- this.#signaler.onIceCandidate((candidate) => {
495
+ this.#signaler.onIceCandidate((data) => {
496
496
  if (this.#pc) {
497
497
  // The promise must not be dropped. addIceCandidate rejects on a
498
498
  // malformed candidate and on one that arrives before the remote
@@ -500,7 +500,7 @@ export class WebRTCTransport {
500
500
  // unhandled rejection ends a Node process by default. webrtc.mjs's
501
501
  // addIceCandidate docblock records this being fixed there; this path
502
502
  // still had it.
503
- Promise.resolve(this.#pc.addIceCandidate(candidate)).catch((e) => {
503
+ Promise.resolve(this.#pc.addIceCandidate(data.candidate)).catch((e) => {
504
504
  silentCatch('clawser-mesh-websocket', 'ignore-rejected-ice-candidate', e);
505
505
  });
506
506
  }
@@ -520,10 +520,10 @@ export class WebRTCTransport {
520
520
  reject(new Error('WebRTC answer timeout'));
521
521
  }, 30000);
522
522
 
523
- this.#signaler.onAnswer(async (answer) => {
523
+ this.#signaler.onAnswer(async (data) => {
524
524
  clearTimeout(timeout);
525
525
  try {
526
- await this.#pc.setRemoteDescription(answer);
526
+ await this.#pc.setRemoteDescription(data.answer);
527
527
  } catch (err) {
528
528
  this.#state = 'disconnected';
529
529
  reject(err);
@@ -563,14 +563,29 @@ export class WebRTCTransport {
563
563
  await this.#pc.setLocalDescription(answer);
564
564
  await this.#signaler.sendAnswer(this.#remotePodId, answer);
565
565
 
566
- // The data channel will arrive via ondatachannel event
567
- this.#pc.addEventListener('datachannel', (ev) => {
568
- this.#dataChannel = ev.channel;
569
- this._attachDataChannelListeners(this.#dataChannel);
570
- if (this.#dataChannel.readyState === 'open') {
571
- this.#state = 'connected';
572
- this._fireEvent('open');
573
- }
566
+ // The data channel will arrive via the ondatachannel event, and callers
567
+ // (e.g. HandshakeCoordinator.acceptConnection) await this method
568
+ // expecting the connection to be ready once it resolves -- so, like
569
+ // connect(), this must not resolve until the data channel is actually
570
+ // open, not merely once it has arrived or once the answer was sent.
571
+ return new Promise((resolve) => {
572
+ this.#pc.addEventListener('datachannel', (ev) => {
573
+ this.#dataChannel = ev.channel;
574
+ this._attachDataChannelListeners(this.#dataChannel);
575
+ if (this.#dataChannel.readyState === 'open') {
576
+ this.#state = 'connected';
577
+ this._fireEvent('open');
578
+ resolve();
579
+ } else {
580
+ const onDCOpen = () => {
581
+ this.#dataChannel.removeEventListener('open', onDCOpen);
582
+ this.#state = 'connected';
583
+ this._fireEvent('open');
584
+ resolve();
585
+ };
586
+ this.#dataChannel.addEventListener('open', onDCOpen);
587
+ }
588
+ });
574
589
  });
575
590
  }
576
591