Skip to content

Commit bfaf30a

Browse files
feat: add onReconnect hook to wsReconnect
1 parent ac1225a commit bfaf30a

File tree

5 files changed

+95
-15
lines changed

5 files changed

+95
-15
lines changed

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,13 @@ The default implementation forwards the `cookie` header.
229229

230230
## `wsReconnect`
231231

232-
The `wsReconnect` option contains the configuration for the WebSocket reconnection feature; is an object with the following properties:
232+
**Experimental.** (default: `disabled`)
233+
234+
Reconnection feature detects and closes broken connections and reconnects automatically, see [how to detect and close broken connections](https://github.com/websockets/ws#how-to-detect-and-close-broken-connections).
235+
The connection is considered broken if the target does not respond to the ping messages or no data is received from the target.
236+
237+
The `wsReconnect` option contains the configuration for the WebSocket reconnection feature.
238+
To enable the feature, set the `wsReconnect` option to an object with the following properties:
233239

234240
- `pingInterval`: The interval between ping messages in ms (default: `30_000`).
235241
- `maxReconnectionRetries`: The maximum number of reconnection retries (`1` to `Infinity`, default: `Infinity`).
@@ -238,9 +244,7 @@ The `wsReconnect` option contains the configuration for the WebSocket reconnecti
238244
- `connectionTimeout`: The timeout for establishing the connection in ms (default: `5_000`).
239245
- `reconnectOnClose`: Whether to reconnect on close, as long as the connection from the related client to the proxy is active (default: `false`).
240246
- `logs`: Whether to log the reconnection process (default: `false`).
241-
242-
Reconnection feature detects and closes broken connections and reconnects automatically, see [how to detect and close broken connections](https://github.com/websockets/ws#how-to-detect-and-close-broken-connections).
243-
The connection is considered broken if the target does not respond to the ping messages or no data is received from the target.
247+
- `onReconnect`: A hook function that is called when the connection is reconnected `async onReconnect(oldSocket, newSocket)` (default: `undefined`).
244248

245249
## Benchmarks
246250

index.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ function proxyWebSockets (source, target) {
112112
/* c8 ignore stop */
113113
}
114114

115-
async function reconnect (logger, source, wsReconnectOptions, targetParams) {
115+
async function reconnect (logger, source, wsReconnectOptions, oldTarget, targetParams) {
116116
const { url, subprotocols, optionsWs } = targetParams
117117

118118
let attempts = 0
@@ -138,21 +138,24 @@ async function reconnect (logger, source, wsReconnectOptions, targetParams) {
138138
}
139139

140140
wsReconnectOptions.logs && logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected')
141+
wsReconnectOptions.onReconnect(oldTarget, target)
141142
proxyWebSocketsWithReconnection(logger, source, target, wsReconnectOptions, targetParams)
142143
}
143144

144-
function proxyWebSocketsWithReconnection (logger, source, target, options, targetParams, fromReconnection = false) {
145+
function proxyWebSocketsWithReconnection (logger, source, target, options, targetParams) {
145146
function close (code, reason) {
146147
target.pingTimer && clearTimeout(source.pingTimer)
147148
target.pingTimer = undefined
148149

149150
// reconnect target as long as the source connection is active
150151
if (source.isAlive && (target.broken || options.reconnectOnClose)) {
152+
// clean up the target and related source listeners
151153
target.isAlive = false
152154
target.removeAllListeners()
153155
// need to specify the listeners to remove
154156
removeSourceListeners(source)
155-
reconnect(logger, source, options, targetParams)
157+
158+
reconnect(logger, source, options, target, targetParams)
156159
return
157160
}
158161

src/options.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const DEFAULT_RECONNECT_DECAY = 1.5
77
const DEFAULT_CONNECTION_TIMEOUT = 5_000
88
const DEFAULT_RECONNECT_ON_CLOSE = false
99
const DEFAULT_LOGS = false
10+
const DEFAULT_ON_RECONNECT = noop
11+
12+
function noop () {}
1013

1114
function validateOptions (options) {
1215
if (!options.upstream && !options.websocket && !((options.upstream === '' || options.wsUpstream === '') && options.replyOptions && typeof options.replyOptions.getUpstream === 'function')) {
@@ -50,6 +53,11 @@ function validateOptions (options) {
5053
throw new Error('wsReconnect.logs must be a boolean')
5154
}
5255
wsReconnect.logs = wsReconnect.logs ?? DEFAULT_LOGS
56+
57+
if (wsReconnect.onReconnect !== undefined && typeof wsReconnect.onReconnect !== 'function') {
58+
throw new Error('wsReconnect.onReconnect must be a function')
59+
}
60+
wsReconnect.onReconnect = wsReconnect.onReconnect ?? DEFAULT_ON_RECONNECT
5361
}
5462

5563
return options
@@ -63,5 +71,6 @@ module.exports = {
6371
DEFAULT_RECONNECT_DECAY,
6472
DEFAULT_CONNECTION_TIMEOUT,
6573
DEFAULT_RECONNECT_ON_CLOSE,
66-
DEFAULT_LOGS
74+
DEFAULT_LOGS,
75+
DEFAULT_ON_RECONNECT
6776
}

test/options.js

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
const { test } = require('node:test')
44
const assert = require('node:assert')
55
const { validateOptions } = require('../src/options')
6-
const { DEFAULT_PING_INTERVAL, DEFAULT_MAX_RECONNECTION_RETRIES, DEFAULT_RECONNECT_INTERVAL, DEFAULT_RECONNECT_DECAY, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_RECONNECT_ON_CLOSE, DEFAULT_LOGS } = require('../src/options')
6+
const { DEFAULT_PING_INTERVAL, DEFAULT_MAX_RECONNECTION_RETRIES, DEFAULT_RECONNECT_INTERVAL, DEFAULT_RECONNECT_DECAY, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_RECONNECT_ON_CLOSE, DEFAULT_LOGS, DEFAULT_ON_RECONNECT } = require('../src/options')
77

88
test('validateOptions', (t) => {
99
const requiredOptions = {
@@ -41,11 +41,26 @@ test('validateOptions', (t) => {
4141
assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { logs: '1' } }), /wsReconnect.logs must be a boolean/)
4242
assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { logs: true } }))
4343

44+
assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { onReconnect: '1' } }), /wsReconnect.onReconnect must be a function/)
45+
assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { onReconnect: () => { } } }))
46+
4447
// set all values
45-
assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: 1, maxReconnectionRetries: 1, reconnectInterval: 100, reconnectDecay: 1, connectionTimeout: 1, reconnectOnClose: true, logs: true } }))
48+
assert.doesNotThrow(() => validateOptions({
49+
...requiredOptions,
50+
wsReconnect: {
51+
pingInterval: 1,
52+
maxReconnectionRetries: 1,
53+
reconnectInterval: 100,
54+
reconnectDecay: 1,
55+
connectionTimeout: 1,
56+
reconnectOnClose: true,
57+
logs: true,
58+
onReconnect: () => { }
59+
}
60+
}))
4661

4762
// get default values
48-
assert.deepEqual(validateOptions({ ...requiredOptions, wsReconnect: { } }), {
63+
assert.deepEqual(validateOptions({ ...requiredOptions, wsReconnect: {} }), {
4964
...requiredOptions,
5065
wsReconnect: {
5166
pingInterval: DEFAULT_PING_INTERVAL,
@@ -54,7 +69,8 @@ test('validateOptions', (t) => {
5469
reconnectDecay: DEFAULT_RECONNECT_DECAY,
5570
connectionTimeout: DEFAULT_CONNECTION_TIMEOUT,
5671
reconnectOnClose: DEFAULT_RECONNECT_ON_CLOSE,
57-
logs: DEFAULT_LOGS
72+
logs: DEFAULT_LOGS,
73+
onReconnect: DEFAULT_ON_RECONNECT
5874
}
5975
})
6076
})

test/ws-reconnect.js

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServe
7373
},
7474
proxy,
7575
client,
76-
loggerSpy
76+
loggerSpy,
77+
logger
7778
}
7879
}
7980

@@ -144,7 +145,7 @@ test('should not reconnect after max retries', async (t) => {
144145
await waitForLogMessage(loggerSpy, 'proxy ws failed to reconnect! No more retries')
145146
})
146147

147-
test('should reconnect on regular target connection close', async (t) => {
148+
test('should not reconnect when the target connection is closed and reconnectOnClose is off', async (t) => {
148149
const wsReconnectOptions = { pingInterval: 200, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectOnClose: false, logs: true }
149150

150151
const { target, loggerSpy } = await createServices({ t, wsReconnectOptions })
@@ -154,7 +155,7 @@ test('should reconnect on regular target connection close', async (t) => {
154155
socket.pong()
155156
})
156157

157-
await wait(1_000)
158+
await wait(500)
158159
socket.close()
159160
})
160161

@@ -197,3 +198,50 @@ test('should reconnect retrying after a few failures', async (t) => {
197198
await createTargetServer(t, { autoPong: true }, targetPort)
198199
await waitForLogMessage(loggerSpy, 'proxy ws reconnected')
199200
})
201+
202+
test('should reconnect when the target connection is closed gracefully and reconnectOnClose is on', async (t) => {
203+
const wsReconnectOptions = { pingInterval: 200, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectOnClose: true, logs: true }
204+
205+
const { target, loggerSpy } = await createServices({ t, wsReconnectOptions })
206+
207+
target.ws.on('connection', async (socket) => {
208+
socket.on('ping', async () => {
209+
socket.pong()
210+
})
211+
212+
await wait(500)
213+
socket.close()
214+
})
215+
216+
await waitForLogMessage(loggerSpy, 'proxy ws target close event')
217+
await waitForLogMessage(loggerSpy, 'proxy ws reconnected')
218+
})
219+
220+
test('should call onReconnect hook function when the connection is reconnected', async (t) => {
221+
const onReconnect = (oldSocket, newSocket) => {
222+
logger.info('onReconnect called')
223+
}
224+
const wsReconnectOptions = {
225+
pingInterval: 100,
226+
reconnectInterval: 100,
227+
maxReconnectionRetries: 1,
228+
reconnectOnClose: true,
229+
logs: true,
230+
onReconnect
231+
}
232+
233+
const { target, loggerSpy, logger } = await createServices({ t, wsReconnectOptions })
234+
235+
target.ws.on('connection', async (socket) => {
236+
socket.on('ping', async () => {
237+
socket.pong()
238+
})
239+
240+
await wait(500)
241+
socket.close()
242+
})
243+
244+
await waitForLogMessage(loggerSpy, 'proxy ws target close event')
245+
await waitForLogMessage(loggerSpy, 'proxy ws reconnected')
246+
await waitForLogMessage(loggerSpy, 'onReconnect called')
247+
})

0 commit comments

Comments
 (0)