@relay-federation/bridge 0.1.0

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.
@@ -0,0 +1,205 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { createHash } from 'node:crypto'
3
+
4
+ // BSV block header is 80 bytes.
5
+ // Timestamp bounds: reject headers more than 2 hours in the future.
6
+ const MAX_FUTURE_SECONDS = 2 * 60 * 60
7
+
8
+ /**
9
+ * Validate a block header.
10
+ *
11
+ * Checks:
12
+ * - Has required fields (height, hash, prevHash)
13
+ * - Hash is a 64-char hex string
14
+ * - prevHash is a 64-char hex string
15
+ *
16
+ * Note: Full PoW validation requires raw 80-byte header data, which our
17
+ * HeaderRelay doesn't carry (it uses {height, hash, prevHash} objects).
18
+ * We validate format and chain linkage here; PoW validation is deferred
19
+ * to when raw headers are available.
20
+ *
21
+ * @param {object} header — { height, hash, prevHash }
22
+ * @returns {{ valid: boolean, reason?: string }}
23
+ */
24
+ export function validateHeader (header) {
25
+ if (!header || typeof header !== 'object') {
26
+ return { valid: false, reason: 'not_an_object' }
27
+ }
28
+
29
+ if (typeof header.height !== 'number' || header.height < 0) {
30
+ return { valid: false, reason: 'invalid_height' }
31
+ }
32
+
33
+ if (typeof header.hash !== 'string' || !/^[0-9a-f]{64}$/i.test(header.hash)) {
34
+ return { valid: false, reason: 'invalid_hash' }
35
+ }
36
+
37
+ if (typeof header.prevHash !== 'string' || !/^[0-9a-f]{64}$/i.test(header.prevHash)) {
38
+ return { valid: false, reason: 'invalid_prevHash' }
39
+ }
40
+
41
+ return { valid: true }
42
+ }
43
+
44
+ /**
45
+ * Validate a transaction.
46
+ *
47
+ * Checks:
48
+ * - txid is a valid 64-char hex string
49
+ * - rawHex is a non-empty hex string
50
+ * - rawHex has minimum tx size (at least 10 bytes = 20 hex chars)
51
+ *
52
+ * @param {string} txid
53
+ * @param {string} rawHex
54
+ * @returns {{ valid: boolean, reason?: string }}
55
+ */
56
+ export function validateTx (txid, rawHex) {
57
+ if (typeof txid !== 'string' || !/^[0-9a-f]{64}$/i.test(txid)) {
58
+ return { valid: false, reason: 'invalid_txid' }
59
+ }
60
+
61
+ if (typeof rawHex !== 'string' || rawHex.length < 20) {
62
+ return { valid: false, reason: 'invalid_raw_hex' }
63
+ }
64
+
65
+ if (!/^[0-9a-f]+$/i.test(rawHex)) {
66
+ return { valid: false, reason: 'not_hex' }
67
+ }
68
+
69
+ // Verify txid matches hash of raw tx
70
+ const hash1 = createHash('sha256').update(Buffer.from(rawHex, 'hex')).digest()
71
+ const hash2 = createHash('sha256').update(hash1).digest()
72
+ const computedTxid = hash2.reverse().toString('hex')
73
+
74
+ if (computedTxid !== txid.toLowerCase()) {
75
+ return { valid: false, reason: 'txid_mismatch' }
76
+ }
77
+
78
+ return { valid: true }
79
+ }
80
+
81
+ /**
82
+ * Validate header chain linkage.
83
+ *
84
+ * Checks that each header's prevHash matches the previous header's hash.
85
+ *
86
+ * @param {Array<{ height: number, hash: string, prevHash: string }>} headers — sorted by height ascending
87
+ * @returns {{ valid: boolean, reason?: string, invalidAt?: number }}
88
+ */
89
+ export function validateHeaderChain (headers) {
90
+ if (!Array.isArray(headers) || headers.length === 0) {
91
+ return { valid: true } // empty chain is valid
92
+ }
93
+
94
+ for (let i = 0; i < headers.length; i++) {
95
+ const check = validateHeader(headers[i])
96
+ if (!check.valid) {
97
+ return { valid: false, reason: `header[${i}]: ${check.reason}`, invalidAt: i }
98
+ }
99
+ }
100
+
101
+ for (let i = 1; i < headers.length; i++) {
102
+ if (headers[i].prevHash !== headers[i - 1].hash) {
103
+ return {
104
+ valid: false,
105
+ reason: `chain_break at height ${headers[i].height}: prevHash doesn't match previous hash`,
106
+ invalidAt: i
107
+ }
108
+ }
109
+ }
110
+
111
+ return { valid: true }
112
+ }
113
+
114
+ /**
115
+ * DataValidator — hooks into PeerManager events and reports
116
+ * data accuracy to PeerScorer.
117
+ *
118
+ * Listens for peer:message events, validates headers and txs,
119
+ * and calls scorer.recordGoodData/recordBadData.
120
+ *
121
+ * Emits:
122
+ * 'validation:fail' — { pubkeyHex, type, reason }
123
+ */
124
+ export class DataValidator extends EventEmitter {
125
+ /**
126
+ * @param {import('./peer-manager.js').PeerManager} peerManager
127
+ * @param {import('./peer-scorer.js').PeerScorer} scorer
128
+ */
129
+ constructor (peerManager, scorer) {
130
+ super()
131
+ this.peerManager = peerManager
132
+ this.scorer = scorer
133
+
134
+ this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
135
+ this._onMessage(pubkeyHex, message)
136
+ })
137
+ }
138
+
139
+ _onMessage (pubkeyHex, message) {
140
+ switch (message.type) {
141
+ case 'headers':
142
+ this._validateHeaders(pubkeyHex, message)
143
+ break
144
+ case 'tx':
145
+ this._validateTx(pubkeyHex, message)
146
+ break
147
+ case 'header_announce':
148
+ this._validateHeaderAnnounce(pubkeyHex, message)
149
+ break
150
+ }
151
+ }
152
+
153
+ _validateHeaders (pubkeyHex, msg) {
154
+ if (!Array.isArray(msg.headers) || msg.headers.length === 0) {
155
+ this.scorer.recordBadData(pubkeyHex)
156
+ this.emit('validation:fail', { pubkeyHex, type: 'headers', reason: 'empty_or_missing' })
157
+ return
158
+ }
159
+
160
+ const chainCheck = validateHeaderChain(msg.headers)
161
+ if (!chainCheck.valid) {
162
+ this.scorer.recordBadData(pubkeyHex)
163
+ this.emit('validation:fail', { pubkeyHex, type: 'headers', reason: chainCheck.reason })
164
+ return
165
+ }
166
+
167
+ // Each valid header counts as a good data point
168
+ for (let i = 0; i < msg.headers.length; i++) {
169
+ this.scorer.recordGoodData(pubkeyHex)
170
+ }
171
+ }
172
+
173
+ _validateTx (pubkeyHex, msg) {
174
+ if (!msg.txid || !msg.rawHex) {
175
+ this.scorer.recordBadData(pubkeyHex)
176
+ this.emit('validation:fail', { pubkeyHex, type: 'tx', reason: 'missing_fields' })
177
+ return
178
+ }
179
+
180
+ const check = validateTx(msg.txid, msg.rawHex)
181
+ if (!check.valid) {
182
+ this.scorer.recordBadData(pubkeyHex)
183
+ this.emit('validation:fail', { pubkeyHex, type: 'tx', reason: check.reason })
184
+ return
185
+ }
186
+
187
+ this.scorer.recordGoodData(pubkeyHex)
188
+ }
189
+
190
+ _validateHeaderAnnounce (pubkeyHex, msg) {
191
+ if (typeof msg.height !== 'number' || msg.height < 0) {
192
+ this.scorer.recordBadData(pubkeyHex)
193
+ this.emit('validation:fail', { pubkeyHex, type: 'header_announce', reason: 'invalid_height' })
194
+ return
195
+ }
196
+
197
+ if (typeof msg.hash !== 'string' || !/^[0-9a-f]{64}$/i.test(msg.hash)) {
198
+ this.scorer.recordBadData(pubkeyHex)
199
+ this.emit('validation:fail', { pubkeyHex, type: 'header_announce', reason: 'invalid_hash' })
200
+ return
201
+ }
202
+
203
+ this.scorer.recordGoodData(pubkeyHex)
204
+ }
205
+ }
@@ -0,0 +1,165 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { PrivateKey } from '@bsv/sdk'
3
+ import { signHash, verifyHash } from '@relay-federation/common/crypto'
4
+ import { SUPPORTED_VERSIONS, HANDSHAKE_TIMEOUT_MS } from '@relay-federation/common/protocol'
5
+
6
+ /**
7
+ * Create a cryptographic handshake helper.
8
+ *
9
+ * Protocol (2 round-trips):
10
+ * 1. Initiator → Responder: { type: "hello", pubkey, nonce, versions, endpoint }
11
+ * 2. Responder → Initiator: { type: "challenge_response", pubkey, nonce, signature, selected_version }
12
+ * 3. Initiator → Responder: { type: "verify", signature }
13
+ * (Responder verifies → connection established)
14
+ *
15
+ * @param {object} opts
16
+ * @param {string} opts.wif — Our WIF private key
17
+ * @param {string} opts.pubkeyHex — Our compressed pubkey hex
18
+ * @param {string} opts.endpoint — Our advertised WSS endpoint
19
+ * @param {string[]} [opts.versions] — Supported protocol versions
20
+ * @returns {object} Handshake helper with methods
21
+ */
22
+ export function createHandshake (opts) {
23
+ const privKey = PrivateKey.fromWif(opts.wif)
24
+ const ourPubkeyHex = opts.pubkeyHex
25
+ const ourEndpoint = opts.endpoint
26
+ const ourVersions = opts.versions || SUPPORTED_VERSIONS
27
+
28
+ return {
29
+ /**
30
+ * Build the initial hello message (initiator side).
31
+ * @returns {{ message: object, nonce: string }}
32
+ */
33
+ createHello () {
34
+ const nonce = randomBytes(32).toString('hex')
35
+ return {
36
+ message: {
37
+ type: 'hello',
38
+ pubkey: ourPubkeyHex,
39
+ nonce,
40
+ versions: ourVersions,
41
+ endpoint: ourEndpoint
42
+ },
43
+ nonce
44
+ }
45
+ },
46
+
47
+ /**
48
+ * Handle an incoming hello and produce a challenge_response (responder side).
49
+ *
50
+ * @param {object} hello — The received hello message
51
+ * @param {Set<string>|null} [registeredPubkeys] — Set of registered pubkeys (if null, skip registry check)
52
+ * @returns {{ message: object, nonce: string, peerPubkey: string, selectedVersion: string } | { error: string }}
53
+ */
54
+ handleHello (hello, registeredPubkeys = null) {
55
+ if (!hello || hello.type !== 'hello' || !hello.pubkey || !hello.nonce || !hello.endpoint) {
56
+ return { error: 'invalid_hello' }
57
+ }
58
+
59
+ if (!Array.isArray(hello.versions) || hello.versions.length === 0) {
60
+ return { error: 'missing_versions' }
61
+ }
62
+
63
+ // Check registry
64
+ if (registeredPubkeys && !registeredPubkeys.has(hello.pubkey)) {
65
+ return { error: 'not_registered' }
66
+ }
67
+
68
+ // Version negotiation — select highest mutual version
69
+ const mutual = hello.versions.filter(v => ourVersions.includes(v))
70
+ if (mutual.length === 0) {
71
+ return { error: 'version_mismatch', supported: ourVersions }
72
+ }
73
+ const selectedVersion = mutual.sort().pop() // highest mutual version
74
+
75
+ // Sign the initiator's nonce to prove our identity
76
+ const signature = signHash(hello.nonce, privKey)
77
+ const responderNonce = randomBytes(32).toString('hex')
78
+
79
+ return {
80
+ message: {
81
+ type: 'challenge_response',
82
+ pubkey: ourPubkeyHex,
83
+ nonce: responderNonce,
84
+ signature,
85
+ selected_version: selectedVersion
86
+ },
87
+ nonce: responderNonce,
88
+ peerPubkey: hello.pubkey,
89
+ selectedVersion
90
+ }
91
+ },
92
+
93
+ /**
94
+ * Verify the challenge_response and produce the verify message (initiator side).
95
+ *
96
+ * @param {object} response — The received challenge_response message
97
+ * @param {string} ourNonce — The nonce we sent in hello
98
+ * @param {Set<string>|null} [registeredPubkeys] — Set of registered pubkeys
99
+ * @returns {{ message: object, peerPubkey: string, selectedVersion: string } | { error: string }}
100
+ */
101
+ handleChallengeResponse (response, ourNonce, registeredPubkeys = null) {
102
+ if (!response || response.type !== 'challenge_response' || !response.pubkey || !response.nonce || !response.signature) {
103
+ return { error: 'invalid_challenge_response' }
104
+ }
105
+
106
+ if (!response.selected_version) {
107
+ return { error: 'missing_version' }
108
+ }
109
+
110
+ // Check registry
111
+ if (registeredPubkeys && !registeredPubkeys.has(response.pubkey)) {
112
+ return { error: 'not_registered' }
113
+ }
114
+
115
+ // Verify responder signed our nonce
116
+ try {
117
+ const valid = verifyHash(ourNonce, response.signature, response.pubkey)
118
+ if (!valid) {
119
+ return { error: 'invalid_signature' }
120
+ }
121
+ } catch {
122
+ return { error: 'invalid_signature' }
123
+ }
124
+
125
+ // Sign responder's nonce
126
+ const signature = signHash(response.nonce, privKey)
127
+
128
+ return {
129
+ message: {
130
+ type: 'verify',
131
+ signature
132
+ },
133
+ peerPubkey: response.pubkey,
134
+ selectedVersion: response.selected_version
135
+ }
136
+ },
137
+
138
+ /**
139
+ * Verify the verify message (responder side — final step).
140
+ *
141
+ * @param {object} verify — The received verify message
142
+ * @param {string} ourNonce — The nonce we sent in challenge_response
143
+ * @param {string} peerPubkeyHex — The initiator's pubkey from hello
144
+ * @returns {{ success: true } | { error: string }}
145
+ */
146
+ handleVerify (verify, ourNonce, peerPubkeyHex) {
147
+ if (!verify || verify.type !== 'verify' || !verify.signature) {
148
+ return { error: 'invalid_verify' }
149
+ }
150
+
151
+ try {
152
+ const valid = verifyHash(ourNonce, verify.signature, peerPubkeyHex)
153
+ if (!valid) {
154
+ return { error: 'invalid_signature' }
155
+ }
156
+ } catch {
157
+ return { error: 'invalid_signature' }
158
+ }
159
+
160
+ return { success: true }
161
+ }
162
+ }
163
+ }
164
+
165
+ export { SUPPORTED_VERSIONS, HANDSHAKE_TIMEOUT_MS }
@@ -0,0 +1,184 @@
1
+ import { EventEmitter } from 'node:events'
2
+
3
+ /**
4
+ * HeaderRelay — syncs block headers between peers.
5
+ *
6
+ * Uses the PeerManager's message infrastructure to:
7
+ * - Announce our best header to new peers (triggered by hello handshake)
8
+ * - Request missing headers from peers that are ahead
9
+ * - Respond to header requests from peers that are behind
10
+ * - Re-announce to all peers after syncing new headers
11
+ *
12
+ * Message types:
13
+ * header_announce — { type, height, hash }
14
+ * header_request — { type, fromHeight }
15
+ * headers — { type, headers: [{ height, hash, prevHash }] }
16
+ *
17
+ * Events:
18
+ * 'header:sync' — { pubkeyHex, added, bestHeight }
19
+ * 'header:behind' — { pubkeyHex, theirHeight, ourHeight }
20
+ */
21
+ export class HeaderRelay extends EventEmitter {
22
+ /**
23
+ * @param {import('./peer-manager.js').PeerManager} peerManager
24
+ * @param {object} [opts]
25
+ * @param {number} [opts.maxBatch=500] — Max headers per response
26
+ */
27
+ constructor (peerManager, opts = {}) {
28
+ super()
29
+ this.peerManager = peerManager
30
+ /** @type {Map<number, { height: number, hash: string, prevHash: string }>} */
31
+ this.headers = new Map()
32
+ this.bestHeight = -1
33
+ this.bestHash = null
34
+ this._maxBatch = opts.maxBatch || 500
35
+
36
+ this.peerManager.on('peer:message', ({ pubkeyHex, message }) => {
37
+ this._handleMessage(pubkeyHex, message)
38
+ })
39
+ }
40
+
41
+ /**
42
+ * Add a single header to the local store.
43
+ * @returns {boolean} true if added, false if duplicate or invalid
44
+ */
45
+ addHeader (header) {
46
+ if (this.headers.has(header.height)) return false
47
+
48
+ // Validate prevHash chain if we have the previous header
49
+ if (header.height > 0 && this.headers.has(header.height - 1)) {
50
+ const prev = this.headers.get(header.height - 1)
51
+ if (prev.hash !== header.prevHash) return false
52
+ }
53
+
54
+ this.headers.set(header.height, header)
55
+ if (header.height > this.bestHeight) {
56
+ this.bestHeight = header.height
57
+ this.bestHash = header.hash
58
+ }
59
+ return true
60
+ }
61
+
62
+ /**
63
+ * Add multiple headers (sorts by height first).
64
+ * @returns {number} count of headers added
65
+ */
66
+ addHeaders (headers) {
67
+ let added = 0
68
+ const sorted = [...headers].sort((a, b) => a.height - b.height)
69
+ for (const h of sorted) {
70
+ if (this.addHeader(h)) added++
71
+ }
72
+ return added
73
+ }
74
+
75
+ /** Get the best (highest) header, or null. */
76
+ getBestHeader () {
77
+ if (this.bestHeight < 0) return null
78
+ return this.headers.get(this.bestHeight)
79
+ }
80
+
81
+ /** Get header at a specific height, or null. */
82
+ getHeader (height) {
83
+ return this.headers.get(height) || null
84
+ }
85
+
86
+ /**
87
+ * Announce our best header to all connected peers.
88
+ * @returns {number} peers notified
89
+ */
90
+ announceToAll () {
91
+ return this.peerManager.broadcast({
92
+ type: 'header_announce',
93
+ height: this.bestHeight,
94
+ hash: this.bestHash
95
+ })
96
+ }
97
+
98
+ /** @private */
99
+ _announceToPeer (pubkeyHex) {
100
+ const conn = this.peerManager.peers.get(pubkeyHex)
101
+ if (conn) {
102
+ conn.send({
103
+ type: 'header_announce',
104
+ height: this.bestHeight,
105
+ hash: this.bestHash
106
+ })
107
+ }
108
+ }
109
+
110
+ /** @private */
111
+ _handleMessage (pubkeyHex, message) {
112
+ switch (message.type) {
113
+ case 'hello':
114
+ // Inbound peer completed handshake — announce our best header
115
+ this._announceToPeer(pubkeyHex)
116
+ break
117
+ case 'header_announce':
118
+ this._onHeaderAnnounce(pubkeyHex, message)
119
+ break
120
+ case 'header_request':
121
+ this._onHeaderRequest(pubkeyHex, message)
122
+ break
123
+ case 'headers':
124
+ this._onHeaders(pubkeyHex, message)
125
+ break
126
+ }
127
+ }
128
+
129
+ /** @private */
130
+ _onHeaderAnnounce (pubkeyHex, msg) {
131
+ if (msg.height > this.bestHeight) {
132
+ // We're behind — request missing headers
133
+ const conn = this.peerManager.peers.get(pubkeyHex)
134
+ if (conn) {
135
+ conn.send({
136
+ type: 'header_request',
137
+ fromHeight: this.bestHeight + 1
138
+ })
139
+ }
140
+ this.emit('header:behind', {
141
+ pubkeyHex,
142
+ theirHeight: msg.height,
143
+ ourHeight: this.bestHeight
144
+ })
145
+ } else if (msg.height < this.bestHeight) {
146
+ // We're ahead — announce back so they can sync from us
147
+ this._announceToPeer(pubkeyHex)
148
+ }
149
+ }
150
+
151
+ /** @private */
152
+ _onHeaderRequest (pubkeyHex, msg) {
153
+ const headers = []
154
+ for (let h = msg.fromHeight; h <= this.bestHeight && headers.length < this._maxBatch; h++) {
155
+ const header = this.headers.get(h)
156
+ if (header) headers.push(header)
157
+ }
158
+ if (headers.length > 0) {
159
+ const conn = this.peerManager.peers.get(pubkeyHex)
160
+ if (conn) {
161
+ conn.send({ type: 'headers', headers })
162
+ }
163
+ }
164
+ }
165
+
166
+ /** @private */
167
+ _onHeaders (pubkeyHex, msg) {
168
+ if (!Array.isArray(msg.headers)) return
169
+ const added = this.addHeaders(msg.headers)
170
+ if (added > 0) {
171
+ this.emit('header:sync', {
172
+ pubkeyHex,
173
+ added,
174
+ bestHeight: this.bestHeight
175
+ })
176
+ // Re-announce to all peers except the source
177
+ this.peerManager.broadcast({
178
+ type: 'header_announce',
179
+ height: this.bestHeight,
180
+ hash: this.bestHash
181
+ }, pubkeyHex)
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,114 @@
1
+ import WebSocket from 'ws'
2
+ import { EventEmitter } from 'node:events'
3
+
4
+ /**
5
+ * PeerConnection — wraps a WebSocket connection to a single peer.
6
+ *
7
+ * JSON message framing: all messages are JSON objects with a `type` field.
8
+ * Supports both outbound (we connect to them) and inbound (they connect to us).
9
+ *
10
+ * Events:
11
+ * 'message' — { type, ...payload } parsed JSON message
12
+ * 'open' — connection established
13
+ * 'close' — connection closed
14
+ * 'error' — connection error
15
+ */
16
+ export class PeerConnection extends EventEmitter {
17
+ /**
18
+ * @param {object} opts
19
+ * @param {string} opts.endpoint - WSS endpoint of the peer
20
+ * @param {string} opts.pubkeyHex - Peer's compressed pubkey (hex)
21
+ * @param {WebSocket} [opts.socket] - Existing socket (for inbound connections)
22
+ */
23
+ constructor (opts) {
24
+ super()
25
+ this.endpoint = opts.endpoint
26
+ this.pubkeyHex = opts.pubkeyHex
27
+ this.ws = opts.socket || null
28
+ this.connected = false
29
+ this._reconnectTimer = null
30
+ this._reconnectDelay = 5000
31
+ this._maxReconnectDelay = 60000
32
+ this._shouldReconnect = !opts.socket // only auto-reconnect outbound
33
+ this._destroyed = false
34
+
35
+ if (opts.socket) {
36
+ this._attachListeners(opts.socket)
37
+ this.connected = opts.socket.readyState === WebSocket.OPEN
38
+ }
39
+ }
40
+
41
+ /** Connect to the peer (outbound only). */
42
+ connect () {
43
+ if (this._destroyed) return
44
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) return
45
+
46
+ try {
47
+ this.ws = new WebSocket(this.endpoint)
48
+ this._attachListeners(this.ws)
49
+ } catch (err) {
50
+ this.emit('error', err)
51
+ this._scheduleReconnect()
52
+ }
53
+ }
54
+
55
+ /** Send a JSON message to the peer. */
56
+ send (msg) {
57
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
58
+ return false
59
+ }
60
+ this.ws.send(JSON.stringify(msg))
61
+ return true
62
+ }
63
+
64
+ /** Close the connection permanently (no reconnect). */
65
+ destroy () {
66
+ this._destroyed = true
67
+ this._shouldReconnect = false
68
+ clearTimeout(this._reconnectTimer)
69
+ if (this.ws) {
70
+ this.ws.close()
71
+ this.ws = null
72
+ }
73
+ this.connected = false
74
+ }
75
+
76
+ _attachListeners (ws) {
77
+ ws.on('open', () => {
78
+ this.connected = true
79
+ this._reconnectDelay = 5000 // reset backoff on success
80
+ this.emit('open')
81
+ })
82
+
83
+ ws.on('message', (data) => {
84
+ try {
85
+ const msg = JSON.parse(data.toString())
86
+ if (msg && typeof msg.type === 'string') {
87
+ this.emit('message', msg)
88
+ }
89
+ } catch (err) {
90
+ // Ignore non-JSON messages
91
+ }
92
+ })
93
+
94
+ ws.on('close', () => {
95
+ this.connected = false
96
+ this.emit('close')
97
+ this._scheduleReconnect()
98
+ })
99
+
100
+ ws.on('error', (err) => {
101
+ this.emit('error', err)
102
+ })
103
+ }
104
+
105
+ _scheduleReconnect () {
106
+ if (!this._shouldReconnect || this._destroyed) return
107
+ clearTimeout(this._reconnectTimer)
108
+ this._reconnectTimer = setTimeout(() => {
109
+ this.connect()
110
+ }, this._reconnectDelay)
111
+ // Exponential backoff, capped
112
+ this._reconnectDelay = Math.min(this._reconnectDelay * 2, this._maxReconnectDelay)
113
+ }
114
+ }