From 584e1f122452a561f13e56b5a18996402a8e2d86 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Tue, 11 Feb 2025 14:54:31 +0100 Subject: [PATCH 01/25] wip --- README.md | 21 +++++++++ index.js | 84 +++++++++++++++++++++++++++++++-- test/ws-reconnect.js | 110 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 test/ws-reconnect.js diff --git a/README.md b/README.md index b7120ca..828cfab 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,27 @@ It also supports an additional `rewriteRequestHeaders(headers, request)` functio opening the WebSocket connection. This function should return an object with the given headers. The default implementation forwards the `cookie` header. +## `wsReconnect` + +The `wsReconnect` option contains the configuration for the WebSocket reconnection feature; is an object with the following properties: + +- `pingInterval`: The interval between ping messages in ms (default: `30_000`). +- `maxReconnectAttempts`: The maximum number of reconnection attempts (default: `3`). +- `maxReconnectionRetries`: The maximum number of reconnection retries (`1` to `Infinity`, default: `Infinity`). +- `reconnectInterval`: The interval between reconnection attempts in ms (default: `1_000`). +- `reconnectDecay`: The decay factor for the reconnection interval (default: `1.5`). +- `connectionTimeout`: The timeout for the connection in ms (default: `5_000`). +- `reconnectOnClose`: Whether to reconnect on close (default: `false`). + +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); the mechanism is based on ping/pong messages. +It verifies the connection status from the service to the target. + +Example: + +```js +TODO +``` + ## Benchmarks The following benchmarks were generated on a dedicated server with an Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz and 64GB of RAM: diff --git a/index.js b/index.js index d355387..fc18952 100644 --- a/index.js +++ b/index.js @@ -44,7 +44,7 @@ function isExternalUrl (url) { return urlPattern.test(url) } -function noop () {} +function noop () { } function proxyWebSockets (source, target) { function close (code, reason) { @@ -76,6 +76,73 @@ function proxyWebSockets (source, target) { /* c8 ignore stop */ } +function reconnect (targetParams) { + const { url, subprotocols, optionsWs } = targetParams + const target = new WebSocket(url, subprotocols, optionsWs) + proxyWebSocketsWithReconnection(source, target, options, targetParams) +} + +function proxyWebSocketsWithReconnection (source, target, options, targetParams) { + function close (code, reason, closing) { + source.pingTimer && clearTimeout(source.pingTimer) + source.pingTimer = undefined + + closeWebSocket(source, code, reason) + closeWebSocket(target, code, reason) + + if (closing) { + source.terminate() + target.terminate() + return + } + + console.log(' >>> reconnect') + + source.isAlive = false + reconnect(targetParams) + } + + source.isAlive = true + source.on('message', (data, binary) => { + source.isAlive = true + waitConnection(target, () => target.send(data, { binary })) + }) + /* c8 ignore start */ + source.on('ping', data => waitConnection(target, () => target.ping(data))) + source.on('pong', data => { + console.log(' >>> pong') + source.isAlive = true + waitConnection(target, () => target.pong(data)) + }) + /* c8 ignore stop */ + source.on('close', (code, reason) => { + close(code, reason, true) + }) + /* c8 ignore start */ + source.on('error', error => close(1011, error.message, false)) + source.on('unexpected-response', () => close(1011, 'unexpected response', false)) + /* c8 ignore stop */ + + source.pingTimer = setInterval(() => { + console.log(' >>> ping') + if (source.isAlive === false) return source.terminate() + source.isAlive = false + source.ping() + }, options.pingInterval).unref() + + // source WebSocket is already connected because it is created by ws server + target.on('message', (data, binary) => source.send(data, { binary })) + /* c8 ignore start */ + target.on('ping', data => source.ping(data)) + /* c8 ignore stop */ + target.on('pong', data => source.pong(data)) + target.on('close', (code, reason) => close(code, reason, true)) + /* c8 ignore start */ + target.on('error', error => close(1011, error.message, false)) + target.on('unexpected-response', () => close(1011, 'unexpected response', false)) + /* c8 ignore stop */ +} + function handleUpgrade (fastify, rawRequest, socket, head) { // Save a reference to the socket and then dispatch the request through the normal fastify router so that it will invoke hooks and then eventually a route handler that might upgrade the socket. rawRequest[kWs] = socket @@ -91,7 +158,7 @@ function handleUpgrade (fastify, rawRequest, socket, head) { } class WebSocketProxy { - constructor (fastify, { wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { + constructor (fastify, { wsReconnect, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { this.logger = fastify.log this.wsClientOptions = { rewriteRequestHeaders: defaultWsHeadersRewrite, @@ -101,6 +168,7 @@ class WebSocketProxy { this.upstream = upstream ? convertUrlToWebSocket(upstream) : '' this.wsUpstream = wsUpstream ? convertUrlToWebSocket(wsUpstream) : '' this.getUpstream = getUpstream + this.wsReconnect = wsReconnect const wss = new WebSocket.Server({ noServer: true, @@ -190,7 +258,13 @@ class WebSocketProxy { const target = new WebSocket(url, subprotocols, optionsWs) this.logger.debug({ url: url.href }, 'proxy websocket') - proxyWebSockets(source, target) + + if (this.wsReconnect) { + const targetParams = { url, subprotocols, optionsWs } + proxyWebSocketsWithReconnection(source, target, this.wsReconnect, targetParams) + } else { + proxyWebSockets(source, target) + } } } @@ -232,6 +306,8 @@ async function fastifyHttpProxy (fastify, opts) { throw new Error('upstream must be specified') } + // TODO validate opts.wsReconnect + const preHandler = opts.preHandler || opts.beforeHandler const rewritePrefix = generateRewritePrefix(fastify.prefix, opts) @@ -341,6 +417,8 @@ async function fastifyHttpProxy (fastify, opts) { } } +// TODO if reconnect on close, terminate connections on shutdown + module.exports = fp(fastifyHttpProxy, { fastify: '5.x', name: '@fastify/http-proxy', diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js new file mode 100644 index 0000000..f903e40 --- /dev/null +++ b/test/ws-reconnect.js @@ -0,0 +1,110 @@ +'use strict' + +const { test } = require('tap') +const Fastify = require('fastify') +const proxyPlugin = require('../') +const WebSocket = require('ws') +const { createServer } = require('node:http') +const { promisify } = require('node:util') +const { once } = require('node:events') +const { setTimeout } = require('node:timers/promises') + +async function createServices({ t, wsReconnectOptions, wsTargetOptions, wsServerOptions }) { + const targetServer = createServer() + const targetWs = new WebSocket.Server({ server: targetServer, ...wsTargetOptions }) + + await promisify(targetServer.listen.bind(targetServer))({ port: 0, host: '127.0.0.1' }) + + const proxy = Fastify() + proxy.register(proxyPlugin, { + upstream: `ws://127.0.0.1:${targetServer.address().port}`, + websocket: true, + wsReconnect: wsReconnectOptions, + wsServerOptions + }) + + await proxy.listen({ port: 0, host: '127.0.0.1' }) + + const client = new WebSocket(`ws://127.0.0.1:${proxy.server.address().port}`) + await once(client, 'open') + + t.teardown(async () => { + client.close() + targetWs.close() + targetServer.close() + await proxy.close() + }) + + return { + target: { + ws: targetWs, + server: targetServer + }, + proxy, + client + } +} + +// TODO use fake timers + +// test('should use ping/pong to verify connection is alive - from source (server on proxy) to target', async (t) => { +// const wsReconnectOptions = { pingInterval: 100 } + +// const { target } = await createServices({t, wsReconnectOptions}) + +// let counter = 0 +// target.ws.on('connection', function connection (ws) { +// ws.on('pong', (data) => { +// console.log(' *** pong', data) +// counter++ +// }) +// }) + +// await setTimeout(250) + +// t.ok(counter > 1) +// }) + +test('should reconnect on broken connection', async (t) => { + const wsReconnectOptions = { pingInterval: 250 } + + const { target } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) + + let counter = 0 + target.ws.on('connection', function connection(ws) { + console.log(' *** connection') + + ws.on('message', async (data) => { + await setTimeout(1000) + console.log(' +++ message', data) + }) + + ws.on('ping', (data) => { + console.log(' *** ping', data) + }) + + ws.on('pong', async (data) => { + console.log(' *** pong', data) + await setTimeout(1000) + counter++ + }) + }) + + await setTimeout(2000) + + t.ok(counter > 1) +}) + +/* +test('should reconnect on source close', async (t) => {}) +test('should reconnect on target close', async (t) => {}) +test('should reconnect on source error', async (t) => {}) +test('should reconnect on target error', async (t) => {}) +test('should reconnect on source unexpected-response', async (t) => {}) +test('should reconnect on target unexpected-response', async (t) => {}) +test('should reconnect on target connection timeout', async (t) => {}) + + if reconnectOnClose ... on shutdown + + with multiple upstreams +*/ From a8dbdfd3f257e74a4d415c77a79d5bd5ec4ad638 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Tue, 11 Feb 2025 16:00:31 +0100 Subject: [PATCH 02/25] wip --- index.js | 85 ++++++++++++++++++++------------ test/ws-reconnect.js | 114 ++++++++++++++++++++----------------------- 2 files changed, 107 insertions(+), 92 deletions(-) diff --git a/index.js b/index.js index fc18952..ed74637 100644 --- a/index.js +++ b/index.js @@ -76,71 +76,92 @@ function proxyWebSockets (source, target) { /* c8 ignore stop */ } -function reconnect (targetParams) { +function reconnect (logger, source, options, targetParams) { const { url, subprotocols, optionsWs } = targetParams const target = new WebSocket(url, subprotocols, optionsWs) - proxyWebSocketsWithReconnection(source, target, options, targetParams) + proxyWebSocketsWithReconnection(logger, source, target, options, targetParams) } -function proxyWebSocketsWithReconnection (source, target, options, targetParams) { - function close (code, reason, closing) { - source.pingTimer && clearTimeout(source.pingTimer) - source.pingTimer = undefined +// source is alive since it is created by the proxy service +function proxyWebSocketsWithReconnection (logger, source, target, options, targetParams) { + function close (code, reason) { + target.pingTimer && clearTimeout(source.pingTimer) + target.pingTimer = undefined - closeWebSocket(source, code, reason) - closeWebSocket(target, code, reason) + if (target.broken) { + logger.warn({ msg: 'proxy ws reconnect on broken connection', target: targetParams.url }) - if (closing) { - source.terminate() - target.terminate() + target.isAlive = false + reconnect(logger, source, options, targetParams) return } - console.log(' >>> reconnect') - - source.isAlive = false - reconnect(targetParams) + logger.info({ msg: 'proxy ws close link' }) + closeWebSocket(source, code, reason) + closeWebSocket(target, code, reason) } - source.isAlive = true source.on('message', (data, binary) => { source.isAlive = true waitConnection(target, () => target.send(data, { binary })) }) /* c8 ignore start */ - source.on('ping', data => waitConnection(target, () => target.ping(data))) + source.on('ping', data => { + waitConnection(target, () => target.ping(data)) + }) source.on('pong', data => { - console.log(' >>> pong') source.isAlive = true waitConnection(target, () => target.pong(data)) }) /* c8 ignore stop */ source.on('close', (code, reason) => { - close(code, reason, true) + logger.warn({ msg: 'proxy ws source close', target: targetParams.url, code, reason }) + close(code, reason) }) /* c8 ignore start */ - source.on('error', error => close(1011, error.message, false)) - source.on('unexpected-response', () => close(1011, 'unexpected response', false)) + source.on('error', error => { + logger.warn({ msg: 'proxy ws source error', target: targetParams.url, error: error.message }) + close(1011, error.message) + }) + source.on('unexpected-response', () => { + logger.warn({ msg: 'proxy ws source unexpected-response', target: targetParams.url }) + close(1011, 'unexpected response') + }) /* c8 ignore stop */ - source.pingTimer = setInterval(() => { - console.log(' >>> ping') - if (source.isAlive === false) return source.terminate() - source.isAlive = false - source.ping() - }, options.pingInterval).unref() - // source WebSocket is already connected because it is created by ws server target.on('message', (data, binary) => source.send(data, { binary })) /* c8 ignore start */ target.on('ping', data => source.ping(data)) /* c8 ignore stop */ target.on('pong', data => source.pong(data)) - target.on('close', (code, reason) => close(code, reason, true)) + target.on('close', (code, reason) => { + logger.warn({ msg: 'proxy ws target close', target: targetParams.url, code, reason }) + close(code, reason) + }) /* c8 ignore start */ - target.on('error', error => close(1011, error.message, false)) - target.on('unexpected-response', () => close(1011, 'unexpected response', false)) + target.on('error', error => { + logger.warn({ msg: 'proxy ws target error', target: targetParams.url, error: error.message }) + close(1011, error.message) + }) + target.on('unexpected-response', () => { + logger.warn({ msg: 'proxy ws target unexpected-response', target: targetParams.url }) + close(1011, 'unexpected response') + }) /* c8 ignore stop */ + + target.isAlive = true + target.pingTimer = setInterval(() => { + if (target.isAlive === false) { + target.broken = true + logger.warn({ msg: 'proxy ws connection is broken', target: targetParams.url }) + target.pingTimer && clearInterval(target.pingTimer) + target.pingTimer = undefined + return target.terminate() + } + target.isAlive = false + target.ping() + }, options.pingInterval).unref() } function handleUpgrade (fastify, rawRequest, socket, head) { @@ -261,7 +282,7 @@ class WebSocketProxy { if (this.wsReconnect) { const targetParams = { url, subprotocols, optionsWs } - proxyWebSocketsWithReconnection(source, target, this.wsReconnect, targetParams) + proxyWebSocketsWithReconnection(this.logger, source, target, this.wsReconnect, targetParams) } else { proxyWebSockets(source, target) } diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index f903e40..87ed39b 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -9,40 +9,41 @@ const { promisify } = require('node:util') const { once } = require('node:events') const { setTimeout } = require('node:timers/promises') -async function createServices({ t, wsReconnectOptions, wsTargetOptions, wsServerOptions }) { - const targetServer = createServer() - const targetWs = new WebSocket.Server({ server: targetServer, ...wsTargetOptions }) - - await promisify(targetServer.listen.bind(targetServer))({ port: 0, host: '127.0.0.1' }) - - const proxy = Fastify() - proxy.register(proxyPlugin, { - upstream: `ws://127.0.0.1:${targetServer.address().port}`, - websocket: true, - wsReconnect: wsReconnectOptions, - wsServerOptions - }) - - await proxy.listen({ port: 0, host: '127.0.0.1' }) - - const client = new WebSocket(`ws://127.0.0.1:${proxy.server.address().port}`) - await once(client, 'open') - - t.teardown(async () => { - client.close() - targetWs.close() - targetServer.close() - await proxy.close() - }) - - return { - target: { - ws: targetWs, - server: targetServer - }, - proxy, - client - } +async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServerOptions }) { + const targetServer = createServer() + const targetWs = new WebSocket.Server({ server: targetServer, ...wsTargetOptions }) + + await promisify(targetServer.listen.bind(targetServer))({ port: 0, host: '127.0.0.1' }) + + // TODO pino-test + const proxy = Fastify() + proxy.register(proxyPlugin, { + upstream: `ws://127.0.0.1:${targetServer.address().port}`, + websocket: true, + wsReconnect: wsReconnectOptions, + wsServerOptions + }) + + await proxy.listen({ port: 0, host: '127.0.0.1' }) + + const client = new WebSocket(`ws://127.0.0.1:${proxy.server.address().port}`) + await once(client, 'open') + + t.teardown(async () => { + client.close() + targetWs.close() + targetServer.close() + await proxy.close() + }) + + return { + target: { + ws: targetWs, + server: targetServer + }, + proxy, + client + } } // TODO use fake timers @@ -50,49 +51,42 @@ async function createServices({ t, wsReconnectOptions, wsTargetOptions, wsServer // test('should use ping/pong to verify connection is alive - from source (server on proxy) to target', async (t) => { // const wsReconnectOptions = { pingInterval: 100 } -// const { target } = await createServices({t, wsReconnectOptions}) +// const { target } = await createServices({ t, wsReconnectOptions }) // let counter = 0 // target.ws.on('connection', function connection (ws) { +// ws.on('ping', (data) => { +// console.log(' *** ping', data) +// counter++ +// }) + // ws.on('pong', (data) => { // console.log(' *** pong', data) -// counter++ // }) // }) // await setTimeout(250) -// t.ok(counter > 1) +// t.ok(counter > 0) // }) test('should reconnect on broken connection', async (t) => { - const wsReconnectOptions = { pingInterval: 250 } - - const { target } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) + const wsReconnectOptions = { pingInterval: 250 } - let counter = 0 - target.ws.on('connection', function connection(ws) { - console.log(' *** connection') + const { target } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) - ws.on('message', async (data) => { - await setTimeout(1000) - console.log(' +++ message', data) - }) + target.ws.on('connection', async (ws) => { + console.log(' *** connection ...') - ws.on('ping', (data) => { - console.log(' *** ping', data) - }) - - ws.on('pong', async (data) => { - console.log(' *** pong', data) - await setTimeout(1000) - counter++ - }) + ws.on('ping', async (data) => { + console.log(' *** received ping:', data) + // latency to break the connection + await setTimeout(1000) + ws.pong(data) + console.log(' *** sent pong after delay') }) - - await setTimeout(2000) - - t.ok(counter > 1) + }) + await setTimeout(3000) }) /* From 8592261112d5f23b9f608925b05a9b9ea15e8d7d Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Tue, 11 Feb 2025 16:21:41 +0100 Subject: [PATCH 03/25] wip --- index.js | 17 ++++++------ test/helper/helper.js | 48 ++++++++++++++++++++++++++++++++ test/ws-reconnect.js | 64 ++++++++++++++++++++----------------------- 3 files changed, 86 insertions(+), 43 deletions(-) create mode 100644 test/helper/helper.js diff --git a/index.js b/index.js index ed74637..b536fde 100644 --- a/index.js +++ b/index.js @@ -79,6 +79,7 @@ function proxyWebSockets (source, target) { function reconnect (logger, source, options, targetParams) { const { url, subprotocols, optionsWs } = targetParams const target = new WebSocket(url, subprotocols, optionsWs) + logger.info({ target: targetParams.url }, 'proxy ws reconnected on broken connection') proxyWebSocketsWithReconnection(logger, source, target, options, targetParams) } @@ -89,8 +90,6 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe target.pingTimer = undefined if (target.broken) { - logger.warn({ msg: 'proxy ws reconnect on broken connection', target: targetParams.url }) - target.isAlive = false reconnect(logger, source, options, targetParams) return @@ -115,16 +114,16 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe }) /* c8 ignore stop */ source.on('close', (code, reason) => { - logger.warn({ msg: 'proxy ws source close', target: targetParams.url, code, reason }) + logger.warn({ target: targetParams.url, code, reason }, 'proxy ws source close') close(code, reason) }) /* c8 ignore start */ source.on('error', error => { - logger.warn({ msg: 'proxy ws source error', target: targetParams.url, error: error.message }) + logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws source error') close(1011, error.message) }) source.on('unexpected-response', () => { - logger.warn({ msg: 'proxy ws source unexpected-response', target: targetParams.url }) + logger.warn({ target: targetParams.url }, 'proxy ws source unexpected-response') close(1011, 'unexpected response') }) /* c8 ignore stop */ @@ -136,16 +135,16 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe /* c8 ignore stop */ target.on('pong', data => source.pong(data)) target.on('close', (code, reason) => { - logger.warn({ msg: 'proxy ws target close', target: targetParams.url, code, reason }) + logger.warn({ target: targetParams.url, code, reason }, 'proxy ws target close') close(code, reason) }) /* c8 ignore start */ target.on('error', error => { - logger.warn({ msg: 'proxy ws target error', target: targetParams.url, error: error.message }) + logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws target error') close(1011, error.message) }) target.on('unexpected-response', () => { - logger.warn({ msg: 'proxy ws target unexpected-response', target: targetParams.url }) + logger.warn({ target: targetParams.url }, 'proxy ws target unexpected-response') close(1011, 'unexpected response') }) /* c8 ignore stop */ @@ -154,7 +153,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe target.pingTimer = setInterval(() => { if (target.isAlive === false) { target.broken = true - logger.warn({ msg: 'proxy ws connection is broken', target: targetParams.url }) + logger.warn({ target: targetParams.url }, 'proxy ws connection is broken') target.pingTimer && clearInterval(target.pingTimer) target.pingTimer = undefined return target.terminate() diff --git a/test/helper/helper.js b/test/helper/helper.js new file mode 100644 index 0000000..a7480e3 --- /dev/null +++ b/test/helper/helper.js @@ -0,0 +1,48 @@ +function createLoggerSpy () { + return { + level: 'trace', + _trace: [], + _debug: [], + _info: [], + _warn: [], + _error: [], + _fatal: [], + + trace: function (...args) { + this._trace.push(args) + }, + debug: function (...args) { + this._debug.push(args) + }, + info: function (...args) { + this._info.push(args) + }, + warn: function (...args) { + this._warn.push(args) + }, + error: function (...args) { + this._error.push(args) + }, + fatal: function (...args) { + this._fatal.push(args) + }, + child: function () { + return this + }, + + reset: function () { + this._trace = [] + this._debug = [] + this._info = [] + this._warn = [] + this._error = [] + this._fatal = [] + } + } +} + +// TODO use pino-test + +module.exports = { + createLoggerSpy +} diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 87ed39b..741c1dc 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -1,13 +1,14 @@ 'use strict' -const { test } = require('tap') -const Fastify = require('fastify') -const proxyPlugin = require('../') -const WebSocket = require('ws') const { createServer } = require('node:http') const { promisify } = require('node:util') const { once } = require('node:events') const { setTimeout } = require('node:timers/promises') +const { test } = require('tap') +const Fastify = require('fastify') +const WebSocket = require('ws') +const proxyPlugin = require('../') +const { createLoggerSpy } = require('./helper/helper') async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServerOptions }) { const targetServer = createServer() @@ -15,8 +16,8 @@ async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServe await promisify(targetServer.listen.bind(targetServer))({ port: 0, host: '127.0.0.1' }) - // TODO pino-test - const proxy = Fastify() + const logger = createLoggerSpy() + const proxy = Fastify({ loggerInstance: logger }) proxy.register(proxyPlugin, { upstream: `ws://127.0.0.1:${targetServer.address().port}`, websocket: true, @@ -42,51 +43,46 @@ async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServe server: targetServer }, proxy, - client + client, + logger } } // TODO use fake timers -// test('should use ping/pong to verify connection is alive - from source (server on proxy) to target', async (t) => { -// const wsReconnectOptions = { pingInterval: 100 } - -// const { target } = await createServices({ t, wsReconnectOptions }) +test('should use ping/pong to verify connection is alive - from source (server on proxy) to target', async (t) => { + const wsReconnectOptions = { pingInterval: 100 } -// let counter = 0 -// target.ws.on('connection', function connection (ws) { -// ws.on('ping', (data) => { -// console.log(' *** ping', data) -// counter++ -// }) + const { target } = await createServices({ t, wsReconnectOptions }) -// ws.on('pong', (data) => { -// console.log(' *** pong', data) -// }) -// }) + let counter = 0 + target.ws.on('connection', function connection (socket) { + socket.on('ping', () => { + counter++ + }) + }) -// await setTimeout(250) + await setTimeout(250) -// t.ok(counter > 0) -// }) + t.ok(counter > 0) +}) test('should reconnect on broken connection', async (t) => { const wsReconnectOptions = { pingInterval: 250 } - const { target } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) - - target.ws.on('connection', async (ws) => { - console.log(' *** connection ...') + const { target, logger } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) - ws.on('ping', async (data) => { - console.log(' *** received ping:', data) + target.ws.on('connection', async (socket) => { + socket.on('ping', async () => { // latency to break the connection - await setTimeout(1000) - ws.pong(data) - console.log(' *** sent pong after delay') + await setTimeout(500) + socket.pong() }) }) - await setTimeout(3000) + await setTimeout(1000) + + t.ok(logger._warn.find(l => l[1] === 'proxy ws connection is broken')) + t.ok(logger._info.find(l => l[1] === 'proxy ws reconnected on broken connection')) }) /* From c6071e7a8a5d189c30e6d9dfa3b57ef82dda1156 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Tue, 11 Feb 2025 17:03:58 +0100 Subject: [PATCH 04/25] wip --- index.js | 10 ++--- src/options.js | 63 ++++++++++++++++++++++++++++++ test/options.js | 92 ++++++++++++++++++++++++++++++++++++++++++++ test/ws-reconnect.js | 2 +- 4 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 src/options.js create mode 100644 test/options.js diff --git a/index.js b/index.js index b536fde..206c07a 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,7 @@ const WebSocket = require('ws') const { convertUrlToWebSocket } = require('./utils') const fp = require('fastify-plugin') const qs = require('fast-querystring') +const { validateOptions } = require('./src/options') const httpMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS'] const urlPattern = /^https?:\/\// @@ -216,6 +217,7 @@ class WebSocketProxy { { const oldClose = fastify.server.close fastify.server.close = function (done) { + // TODO if reconnect on close, terminate connections on shutdown wss.close(() => { oldClose.call(this, (err) => { done && done(err) @@ -322,11 +324,7 @@ function generateRewritePrefix (prefix, opts) { } async function fastifyHttpProxy (fastify, opts) { - if (!opts.upstream && !opts.websocket && !((opts.upstream === '' || opts.wsUpstream === '') && opts.replyOptions && typeof opts.replyOptions.getUpstream === 'function')) { - throw new Error('upstream must be specified') - } - - // TODO validate opts.wsReconnect + opts = validateOptions(opts) const preHandler = opts.preHandler || opts.beforeHandler const rewritePrefix = generateRewritePrefix(fastify.prefix, opts) @@ -437,8 +435,6 @@ async function fastifyHttpProxy (fastify, opts) { } } -// TODO if reconnect on close, terminate connections on shutdown - module.exports = fp(fastifyHttpProxy, { fastify: '5.x', name: '@fastify/http-proxy', diff --git a/src/options.js b/src/options.js new file mode 100644 index 0000000..01ee2c0 --- /dev/null +++ b/src/options.js @@ -0,0 +1,63 @@ +'use strict' + +const DEFAULT_PING_INTERVAL = 30_000 +const DEFAULT_MAX_RECONNECT_ATTEMPTS = 3 +const DEFAULT_MAX_RECONNECTION_RETRIES = Infinity +const DEFAULT_RECONNECT_INTERVAL = 1_000 +const DEFAULT_RECONNECT_DECAY = 1.5 +const DEFAULT_CONNECTION_TIMEOUT = 5_000 +const DEFAULT_RECONNECT_ON_CLOSE = false + +function validateOptions (options) { + if (!options.upstream && !options.websocket && !((options.upstream === '' || options.wsUpstream === '') && options.replyOptions && typeof options.replyOptions.getUpstream === 'function')) { + throw new Error('upstream must be specified') + } + + if (options.wsReconnect) { + const wsReconnect = options.wsReconnect + + if (wsReconnect.pingInterval !== undefined && (typeof wsReconnect.pingInterval !== 'number' || wsReconnect.pingInterval < 0)) { + throw new Error('wsReconnect.pingInterval must be a non-negative number') + } + wsReconnect.pingInterval = wsReconnect.pingInterval ?? DEFAULT_PING_INTERVAL + + if (wsReconnect.maxReconnectAttempts !== undefined && (typeof wsReconnect.maxReconnectAttempts !== 'number' || wsReconnect.maxReconnectAttempts < 0)) { + throw new Error('wsReconnect.maxReconnectAttempts must be a non-negative number') + } + wsReconnect.maxReconnectAttempts = wsReconnect.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS + + if (wsReconnect.maxReconnectionRetries !== undefined) { + if (typeof wsReconnect.maxReconnectionRetries !== 'number' || wsReconnect.maxReconnectionRetries < 1) { + throw new Error('wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1') + } + } + wsReconnect.maxReconnectionRetries = wsReconnect.maxReconnectionRetries ?? DEFAULT_MAX_RECONNECTION_RETRIES + + if (wsReconnect.reconnectInterval !== undefined && (typeof wsReconnect.reconnectInterval !== 'number' || wsReconnect.reconnectInterval < 0)) { + throw new Error('wsReconnect.reconnectInterval must be a non-negative number') + } + wsReconnect.reconnectInterval = wsReconnect.reconnectInterval ?? DEFAULT_RECONNECT_INTERVAL + + if (wsReconnect.reconnectDecay !== undefined && (typeof wsReconnect.reconnectDecay !== 'number' || wsReconnect.reconnectDecay < 1)) { + throw new Error('wsReconnect.reconnectDecay must be a number greater than or equal to 1') + } + wsReconnect.reconnectDecay = wsReconnect.reconnectDecay ?? DEFAULT_RECONNECT_DECAY + + if (wsReconnect.connectionTimeout !== undefined && (typeof wsReconnect.connectionTimeout !== 'number' || wsReconnect.connectionTimeout < 0)) { + throw new Error('wsReconnect.connectionTimeout must be a non-negative number') + } + wsReconnect.connectionTimeout = wsReconnect.connectionTimeout ?? DEFAULT_CONNECTION_TIMEOUT + + if (wsReconnect.reconnectOnClose !== undefined && typeof wsReconnect.reconnectOnClose !== 'boolean') { + throw new Error('wsReconnect.reconnectOnClose must be a boolean') + } + wsReconnect.reconnectOnClose = wsReconnect.reconnectOnClose ?? DEFAULT_RECONNECT_ON_CLOSE + } + + return options + +} + +module.exports = { + validateOptions +} diff --git a/test/options.js b/test/options.js new file mode 100644 index 0000000..1159bf0 --- /dev/null +++ b/test/options.js @@ -0,0 +1,92 @@ +'use strict' + +const { test } = require('tap') +const { validateOptions } = require('../src/options') + +test('validateOptions', (t) => { + const requiredOptions = { + upstream: 'someUpstream' + } + + t.throws(() => validateOptions({}), 'throws error if neither upstream nor websocket is specified') + t.doesNotThrow(() => validateOptions({ upstream: 'someUpstream' })) + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: -1 } }), 'wsReconnect.pingInterval must be a non-negative number') + + { + const options = { ...requiredOptions, wsReconnect: {} } + validateOptions(options) + t.equal(options.wsReconnect.pingInterval, 30_000, 'sets default pingInterval if not specified') + } + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectAttempts: -1 } }), 'wsReconnect.maxReconnectAttempts must be a non-negative number') + + { + const options = { ...requiredOptions, wsReconnect: {} } + validateOptions(options) + t.equal(options.wsReconnect.maxReconnectAttempts, 3, 'sets default maxReconnectAttempts if not specified') + } + + { + const options = { ...requiredOptions, wsReconnect: {} } + validateOptions(options) + t.equal(options.wsReconnect.maxReconnectionRetries, Infinity, 'sets default maxReconnectionRetries if not specified') + } + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: -1 } }), 'wsReconnect.reconnectInterval must be a non-negative number') + + { + const options = { ...requiredOptions, wsReconnect: {} } + validateOptions(options) + t.equal(options.wsReconnect.reconnectInterval, 1_000, 'sets default reconnectInterval if not specified') + } + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: 0.5 } }), 'wsReconnect.reconnectDecay must be a number greater than or equal to 1') + + { + const options = { ...requiredOptions, wsReconnect: {} } + validateOptions(options) + t.equal(options.wsReconnect.reconnectDecay, 1.5, 'sets default reconnectDecay if not specified') + } + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: -1 } }), 'wsReconnect.connectionTimeout must be a non-negative number') + + { + const options = { ...requiredOptions, wsReconnect: {} } + validateOptions(options) + t.equal(options.wsReconnect.connectionTimeout, 5_000, 'sets default connectionTimeout if not specified') + } + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: 'notBoolean' } }), 'wsReconnect.reconnectOnClose must be a boolean') + + { + const options = { ...requiredOptions, wsReconnect: {} } + validateOptions(options) + t.equal(options.wsReconnect.reconnectOnClose, false, 'sets default reconnectOnClose if not specified') + } + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: 0.5 } }), 'wsReconnect.reconnectDecay must be a number greater than or equal to 1') + + { + const options = { ...requiredOptions, wsReconnect: {} } + validateOptions(options) + t.equal(options.wsReconnect.reconnectDecay, 1.5, 'sets default reconnectDecay if not specified') + } + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: -1 } }), 'wsReconnect.connectionTimeout must be a non-negative number') + + { + const options = { ...requiredOptions, wsReconnect: {} } + validateOptions(options) + t.equal(options.wsReconnect.connectionTimeout, 5_000, 'sets default connectionTimeout if not specified') + } + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: 'notBoolean' } }), 'wsReconnect.reconnectOnClose must be a boolean') + + { + const options = { ...requiredOptions, wsReconnect: {} } + validateOptions(options) + t.equal(options.wsReconnect.reconnectOnClose, false, 'sets default reconnectOnClose if not specified') + } + + t.end() +}) diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 741c1dc..217fd41 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -74,7 +74,7 @@ test('should reconnect on broken connection', async (t) => { target.ws.on('connection', async (socket) => { socket.on('ping', async () => { - // latency to break the connection + // add latency to break the connection await setTimeout(500) socket.pong() }) From b827e9bd432ea45dd0fefa8f20e522fecde2a74d Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Tue, 11 Feb 2025 17:21:54 +0100 Subject: [PATCH 05/25] wip --- index.js | 31 ++++++++++++++++++++++++++++--- src/options.js | 4 ++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 206c07a..bff9b42 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ 'use strict' +const { setTimeout: wait } = require('node:timers/promises') const From = require('@fastify/reply-from') const { ServerResponse } = require('node:http') const WebSocket = require('ws') @@ -77,10 +78,32 @@ function proxyWebSockets (source, target) { /* c8 ignore stop */ } -function reconnect (logger, source, options, targetParams) { +async function reconnect (logger, source, options, targetParams) { + // TODO retry, maxReconnectionRetries const { url, subprotocols, optionsWs } = targetParams - const target = new WebSocket(url, subprotocols, optionsWs) - logger.info({ target: targetParams.url }, 'proxy ws reconnected on broken connection') + + let attempts = 0 + let target + do { + const reconnectWait = options.wsReconnect.reconnectInterval * options.wsReconnect.reconnectDecay + logger.info({ target: targetParams.url, attempts }, `proxy ws reconnecting in ${reconnectWait} ms`) + await wait(reconnectWait) + target = new WebSocket(url, subprotocols, optionsWs) + // TODO connectionTimeout + if (!target) { + attempts++ + continue + } + break + } while (attempts < options.wsReconnect.maxReconnectAttempts) + + if (!target) { + logger.error({ target: targetParams.url }, 'proxy ws failed to reconnect') + // TODO onError hook? + return + } + + logger.info({ target: targetParams.url }, 'proxy ws reconnected') proxyWebSocketsWithReconnection(logger, source, target, options, targetParams) } @@ -96,6 +119,8 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe return } + // TODO if reconnectOnClose + logger.info({ msg: 'proxy ws close link' }) closeWebSocket(source, code, reason) closeWebSocket(target, code, reason) diff --git a/src/options.js b/src/options.js index 01ee2c0..8c3d543 100644 --- a/src/options.js +++ b/src/options.js @@ -33,8 +33,8 @@ function validateOptions (options) { } wsReconnect.maxReconnectionRetries = wsReconnect.maxReconnectionRetries ?? DEFAULT_MAX_RECONNECTION_RETRIES - if (wsReconnect.reconnectInterval !== undefined && (typeof wsReconnect.reconnectInterval !== 'number' || wsReconnect.reconnectInterval < 0)) { - throw new Error('wsReconnect.reconnectInterval must be a non-negative number') + if (wsReconnect.reconnectInterval !== undefined && (typeof wsReconnect.reconnectInterval !== 'number' || wsReconnect.reconnectInterval < 100)) { + throw new Error('wsReconnect.reconnectInterval (ms) must be a number greater than or equal to 100') } wsReconnect.reconnectInterval = wsReconnect.reconnectInterval ?? DEFAULT_RECONNECT_INTERVAL From 54ad7a4f1aef19455a64145b515b0872aea47c28 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Wed, 12 Feb 2025 13:58:42 +0100 Subject: [PATCH 06/25] wip --- README.md | 6 ++-- index.js | 65 +++++++++++++++++++++++++---------- src/options.js | 25 +++++++------- test/options.js | 80 +------------------------------------------- test/ws-reconnect.js | 71 ++++++++++++++++++++++++++++----------- 5 files changed, 115 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index 828cfab..1afe340 100644 --- a/README.md +++ b/README.md @@ -232,12 +232,12 @@ The default implementation forwards the `cookie` header. The `wsReconnect` option contains the configuration for the WebSocket reconnection feature; is an object with the following properties: - `pingInterval`: The interval between ping messages in ms (default: `30_000`). -- `maxReconnectAttempts`: The maximum number of reconnection attempts (default: `3`). -- `maxReconnectionRetries`: The maximum number of reconnection retries (`1` to `Infinity`, default: `Infinity`). +- `maxReconnectionRetries`: The maximum number of reconnection attempts (default: `3`). - `reconnectInterval`: The interval between reconnection attempts in ms (default: `1_000`). - `reconnectDecay`: The decay factor for the reconnection interval (default: `1.5`). - `connectionTimeout`: The timeout for the connection in ms (default: `5_000`). -- `reconnectOnClose`: Whether to reconnect on close (default: `false`). +- `reconnectOnClose`: Whether to reconnect on close, as long as the source connection is active (default: `false`). +- TODO logs option? 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); the mechanism is based on ping/pong messages. It verifies the connection status from the service to the target. diff --git a/index.js b/index.js index bff9b42..2fab87f 100644 --- a/index.js +++ b/index.js @@ -34,6 +34,7 @@ function closeWebSocket (socket, code, reason) { } } +// TODO timeout function waitConnection (socket, write) { if (socket.readyState === WebSocket.CONNECTING) { socket.once('open', write) @@ -42,6 +43,35 @@ function waitConnection (socket, write) { } } +// TODO timeout +// TODO merge with waitConnection +function waitForConnection (target, timeout) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error('WebSocket connection timeout')) + }, timeout) + + if (target.readyState === WebSocket.OPEN) { + clearTimeout(timeoutId) + return resolve() + } + + if (target.readyState === WebSocket.CONNECTING) { + target.once('open', () => { + clearTimeout(timeoutId) + resolve() + }) + target.once('error', (err) => { + clearTimeout(timeoutId) + reject(err) + }) + } else { + clearTimeout(timeoutId) + reject(new Error('WebSocket is closed')) + } + }) +} + function isExternalUrl (url) { return urlPattern.test(url) } @@ -78,33 +108,33 @@ function proxyWebSockets (source, target) { /* c8 ignore stop */ } -async function reconnect (logger, source, options, targetParams) { - // TODO retry, maxReconnectionRetries +async function reconnect (logger, source, wsReconnectOptions, targetParams) { const { url, subprotocols, optionsWs } = targetParams let attempts = 0 let target do { - const reconnectWait = options.wsReconnect.reconnectInterval * options.wsReconnect.reconnectDecay + const reconnectWait = wsReconnectOptions.reconnectInterval * (wsReconnectOptions.reconnectDecay * attempts || 1) logger.info({ target: targetParams.url, attempts }, `proxy ws reconnecting in ${reconnectWait} ms`) await wait(reconnectWait) - target = new WebSocket(url, subprotocols, optionsWs) - // TODO connectionTimeout - if (!target) { + + try { + target = new WebSocket(url, subprotocols, optionsWs) + await waitForConnection(target, wsReconnectOptions.connectionTimeout) + } catch (err) { + logger.error({ target: targetParams.url, err, attempts }, 'proxy ws reconnect error') attempts++ - continue + target = undefined } - break - } while (attempts < options.wsReconnect.maxReconnectAttempts) - + } while (!target && attempts < wsReconnectOptions.maxReconnectionRetries) + if (!target) { - logger.error({ target: targetParams.url }, 'proxy ws failed to reconnect') - // TODO onError hook? + logger.error({ target: targetParams.url, attempts }, 'proxy ws failed to reconnect') return } - logger.info({ target: targetParams.url }, 'proxy ws reconnected') - proxyWebSocketsWithReconnection(logger, source, target, options, targetParams) + logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected') + proxyWebSocketsWithReconnection(logger, source, target, wsReconnectOptions, targetParams) } // source is alive since it is created by the proxy service @@ -113,14 +143,14 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe target.pingTimer && clearTimeout(source.pingTimer) target.pingTimer = undefined - if (target.broken) { + // endless reconnect on close + // as long as the source connection is active + if (source.isAlive && (target.broken || options.reconnectOnClose)) { target.isAlive = false reconnect(logger, source, options, targetParams) return } - // TODO if reconnectOnClose - logger.info({ msg: 'proxy ws close link' }) closeWebSocket(source, code, reason) closeWebSocket(target, code, reason) @@ -242,7 +272,6 @@ class WebSocketProxy { { const oldClose = fastify.server.close fastify.server.close = function (done) { - // TODO if reconnect on close, terminate connections on shutdown wss.close(() => { oldClose.call(this, (err) => { done && done(err) diff --git a/src/options.js b/src/options.js index 8c3d543..fe10582 100644 --- a/src/options.js +++ b/src/options.js @@ -12,7 +12,7 @@ function validateOptions (options) { if (!options.upstream && !options.websocket && !((options.upstream === '' || options.wsUpstream === '') && options.replyOptions && typeof options.replyOptions.getUpstream === 'function')) { throw new Error('upstream must be specified') } - + if (options.wsReconnect) { const wsReconnect = options.wsReconnect @@ -21,17 +21,10 @@ function validateOptions (options) { } wsReconnect.pingInterval = wsReconnect.pingInterval ?? DEFAULT_PING_INTERVAL - if (wsReconnect.maxReconnectAttempts !== undefined && (typeof wsReconnect.maxReconnectAttempts !== 'number' || wsReconnect.maxReconnectAttempts < 0)) { - throw new Error('wsReconnect.maxReconnectAttempts must be a non-negative number') - } - wsReconnect.maxReconnectAttempts = wsReconnect.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS - - if (wsReconnect.maxReconnectionRetries !== undefined) { - if (typeof wsReconnect.maxReconnectionRetries !== 'number' || wsReconnect.maxReconnectionRetries < 1) { - throw new Error('wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1') - } + if (wsReconnect.maxReconnectionRetries !== undefined && (typeof wsReconnect.maxReconnectionRetries !== 'number' || wsReconnect.maxReconnectionRetries < 0)) { + throw new Error('wsReconnect.maxReconnectionRetries must be a non-negative number') } - wsReconnect.maxReconnectionRetries = wsReconnect.maxReconnectionRetries ?? DEFAULT_MAX_RECONNECTION_RETRIES + wsReconnect.maxReconnectionRetries = wsReconnect.maxReconnectionRetries ?? DEFAULT_MAX_RECONNECT_ATTEMPTS if (wsReconnect.reconnectInterval !== undefined && (typeof wsReconnect.reconnectInterval !== 'number' || wsReconnect.reconnectInterval < 100)) { throw new Error('wsReconnect.reconnectInterval (ms) must be a number greater than or equal to 100') @@ -55,9 +48,15 @@ function validateOptions (options) { } return options - } module.exports = { - validateOptions + validateOptions, + DEFAULT_PING_INTERVAL, + DEFAULT_MAX_RECONNECT_ATTEMPTS, + DEFAULT_MAX_RECONNECTION_RETRIES, + DEFAULT_RECONNECT_INTERVAL, + DEFAULT_RECONNECT_DECAY, + DEFAULT_CONNECTION_TIMEOUT, + DEFAULT_RECONNECT_ON_CLOSE } diff --git a/test/options.js b/test/options.js index 1159bf0..ba70bb0 100644 --- a/test/options.js +++ b/test/options.js @@ -8,85 +8,7 @@ test('validateOptions', (t) => { upstream: 'someUpstream' } - t.throws(() => validateOptions({}), 'throws error if neither upstream nor websocket is specified') - t.doesNotThrow(() => validateOptions({ upstream: 'someUpstream' })) - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: -1 } }), 'wsReconnect.pingInterval must be a non-negative number') - - { - const options = { ...requiredOptions, wsReconnect: {} } - validateOptions(options) - t.equal(options.wsReconnect.pingInterval, 30_000, 'sets default pingInterval if not specified') - } - - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectAttempts: -1 } }), 'wsReconnect.maxReconnectAttempts must be a non-negative number') - - { - const options = { ...requiredOptions, wsReconnect: {} } - validateOptions(options) - t.equal(options.wsReconnect.maxReconnectAttempts, 3, 'sets default maxReconnectAttempts if not specified') - } - - { - const options = { ...requiredOptions, wsReconnect: {} } - validateOptions(options) - t.equal(options.wsReconnect.maxReconnectionRetries, Infinity, 'sets default maxReconnectionRetries if not specified') - } - - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: -1 } }), 'wsReconnect.reconnectInterval must be a non-negative number') - - { - const options = { ...requiredOptions, wsReconnect: {} } - validateOptions(options) - t.equal(options.wsReconnect.reconnectInterval, 1_000, 'sets default reconnectInterval if not specified') - } - - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: 0.5 } }), 'wsReconnect.reconnectDecay must be a number greater than or equal to 1') - - { - const options = { ...requiredOptions, wsReconnect: {} } - validateOptions(options) - t.equal(options.wsReconnect.reconnectDecay, 1.5, 'sets default reconnectDecay if not specified') - } - - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: -1 } }), 'wsReconnect.connectionTimeout must be a non-negative number') - - { - const options = { ...requiredOptions, wsReconnect: {} } - validateOptions(options) - t.equal(options.wsReconnect.connectionTimeout, 5_000, 'sets default connectionTimeout if not specified') - } - - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: 'notBoolean' } }), 'wsReconnect.reconnectOnClose must be a boolean') - - { - const options = { ...requiredOptions, wsReconnect: {} } - validateOptions(options) - t.equal(options.wsReconnect.reconnectOnClose, false, 'sets default reconnectOnClose if not specified') - } - - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: 0.5 } }), 'wsReconnect.reconnectDecay must be a number greater than or equal to 1') - - { - const options = { ...requiredOptions, wsReconnect: {} } - validateOptions(options) - t.equal(options.wsReconnect.reconnectDecay, 1.5, 'sets default reconnectDecay if not specified') - } - - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: -1 } }), 'wsReconnect.connectionTimeout must be a non-negative number') - - { - const options = { ...requiredOptions, wsReconnect: {} } - validateOptions(options) - t.equal(options.wsReconnect.connectionTimeout, 5_000, 'sets default connectionTimeout if not specified') - } - - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: 'notBoolean' } }), 'wsReconnect.reconnectOnClose must be a boolean') - - { - const options = { ...requiredOptions, wsReconnect: {} } - validateOptions(options) - t.equal(options.wsReconnect.reconnectOnClose, false, 'sets default reconnectOnClose if not specified') - } + // TODO t.end() }) diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 217fd41..53df3a0 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -3,7 +3,7 @@ const { createServer } = require('node:http') const { promisify } = require('node:util') const { once } = require('node:events') -const { setTimeout } = require('node:timers/promises') +const { setTimeout: wait } = require('node:timers/promises') const { test } = require('tap') const Fastify = require('fastify') const WebSocket = require('ws') @@ -48,10 +48,11 @@ async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServe } } -// TODO use fake timers +// TODO use fake timers ? +/* test('should use ping/pong to verify connection is alive - from source (server on proxy) to target', async (t) => { - const wsReconnectOptions = { pingInterval: 100 } + const wsReconnectOptions = { pingInterval: 100, reconnectInterval: 100, maxReconnectionRetries: 1 } const { target } = await createServices({ t, wsReconnectOptions }) @@ -62,39 +63,71 @@ test('should use ping/pong to verify connection is alive - from source (server o }) }) - await setTimeout(250) + await wait(250) t.ok(counter > 0) }) test('should reconnect on broken connection', async (t) => { - const wsReconnectOptions = { pingInterval: 250 } + const wsReconnectOptions = { pingInterval: 250, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectDecay: 2 } const { target, logger } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) target.ws.on('connection', async (socket) => { socket.on('ping', async () => { // add latency to break the connection - await setTimeout(500) + await wait(500) socket.pong() }) }) - await setTimeout(1000) + await wait(1000) t.ok(logger._warn.find(l => l[1] === 'proxy ws connection is broken')) - t.ok(logger._info.find(l => l[1] === 'proxy ws reconnected on broken connection')) + t.ok(logger._info.find(l => l[1] === 'proxy ws reconnecting in 100 ms')) + t.ok(logger._info.find(l => l[1] === 'proxy ws reconnected')) }) +*/ -/* -test('should reconnect on source close', async (t) => {}) -test('should reconnect on target close', async (t) => {}) -test('should reconnect on source error', async (t) => {}) -test('should reconnect on target error', async (t) => {}) -test('should reconnect on source unexpected-response', async (t) => {}) -test('should reconnect on target unexpected-response', async (t) => {}) -test('should reconnect on target connection timeout', async (t) => {}) +test('should reconnect after failingwith retries', async (t) => { + const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, reconnectOnClose: true } - if reconnectOnClose ... on shutdown + const { target, logger } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) - with multiple upstreams -*/ + const refuseNewConnections = false + + target.ws.on('connection', async (socket) => { + socket.on('ping', async () => { + // add latency to break the connection + await wait(500) + socket.pong() + }) + }) + + target.ws.on('upgrade', (request, socket, head) => { + if (refuseNewConnections) { + socket.destroy() + } + }) + + // TODO use pino-test + // await pinoTest.once(logger, 'warn', 'proxy ws connection is broken') + + // close the target server to fail new connections + // setTimeout(() => { + // refuseNewConnections = true + // setTimeout(() => { + // refuseNewConnections = false + // }, 500) + // }, 1000) + + // t.ok(logger._warn.find(l => l[1] === 'proxy ws connection is broken')) + // t.ok(logger._info.find(l => l[1] === 'proxy ws reconnecting in 100 ms')) + // t.ok(logger._error.find(l => l[1] === 'proxy ws reconnect error' && l[0].attempts === 1)) + // t.ok(logger._info.find(l => l[1] === 'proxy ws reconnected' && l[0].attempts === 2)) +}) + +// TODO reconnect fails becase of timeout +// cant reconnect +// TODO reconnect on close/error/unexpected-response +// TODO reconnectOnClose ... on shutdown +// TODO check only socket to target From 9350edc5dff071234403442b19fdd85b5fe94d0a Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Wed, 12 Feb 2025 14:12:37 +0100 Subject: [PATCH 07/25] wip --- package.json | 2 ++ test/helper/helper.js | 48 ------------------------------------------- test/ws-reconnect.js | 20 +++++++++--------- 3 files changed, 12 insertions(+), 58 deletions(-) delete mode 100644 test/helper/helper.js diff --git a/package.json b/package.json index 7ce674e..1e9fdcc 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,8 @@ "http-errors": "^2.0.0", "http-proxy": "^1.18.1", "neostandard": "^0.12.0", + "pino": "^9.6.0", + "pino-test": "^1.1.0", "simple-get": "^4.0.1", "socket.io": "^4.7.5", "socket.io-client": "^4.7.5", diff --git a/test/helper/helper.js b/test/helper/helper.js deleted file mode 100644 index a7480e3..0000000 --- a/test/helper/helper.js +++ /dev/null @@ -1,48 +0,0 @@ -function createLoggerSpy () { - return { - level: 'trace', - _trace: [], - _debug: [], - _info: [], - _warn: [], - _error: [], - _fatal: [], - - trace: function (...args) { - this._trace.push(args) - }, - debug: function (...args) { - this._debug.push(args) - }, - info: function (...args) { - this._info.push(args) - }, - warn: function (...args) { - this._warn.push(args) - }, - error: function (...args) { - this._error.push(args) - }, - fatal: function (...args) { - this._fatal.push(args) - }, - child: function () { - return this - }, - - reset: function () { - this._trace = [] - this._debug = [] - this._info = [] - this._warn = [] - this._error = [] - this._fatal = [] - } - } -} - -// TODO use pino-test - -module.exports = { - createLoggerSpy -} diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 53df3a0..0cb90da 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -7,8 +7,9 @@ const { setTimeout: wait } = require('node:timers/promises') const { test } = require('tap') const Fastify = require('fastify') const WebSocket = require('ws') +const pinoTest = require('pino-test') +const pino = require('pino') const proxyPlugin = require('../') -const { createLoggerSpy } = require('./helper/helper') async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServerOptions }) { const targetServer = createServer() @@ -16,7 +17,8 @@ async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServe await promisify(targetServer.listen.bind(targetServer))({ port: 0, host: '127.0.0.1' }) - const logger = createLoggerSpy() + const loggerSpy = pinoTest.sink() + const logger = pino(loggerSpy) const proxy = Fastify({ loggerInstance: logger }) proxy.register(proxyPlugin, { upstream: `ws://127.0.0.1:${targetServer.address().port}`, @@ -44,13 +46,12 @@ async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServe }, proxy, client, - logger + loggerSpy } } // TODO use fake timers ? -/* test('should use ping/pong to verify connection is alive - from source (server on proxy) to target', async (t) => { const wsReconnectOptions = { pingInterval: 100, reconnectInterval: 100, maxReconnectionRetries: 1 } @@ -71,7 +72,7 @@ test('should use ping/pong to verify connection is alive - from source (server o test('should reconnect on broken connection', async (t) => { const wsReconnectOptions = { pingInterval: 250, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectDecay: 2 } - const { target, logger } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) + const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) target.ws.on('connection', async (socket) => { socket.on('ping', async () => { @@ -80,13 +81,12 @@ test('should reconnect on broken connection', async (t) => { socket.pong() }) }) - await wait(1000) - t.ok(logger._warn.find(l => l[1] === 'proxy ws connection is broken')) - t.ok(logger._info.find(l => l[1] === 'proxy ws reconnecting in 100 ms')) - t.ok(logger._info.find(l => l[1] === 'proxy ws reconnected')) + await pinoTest.once(loggerSpy, 'warn', 'proxy ws connection is broken') + await pinoTest.once(loggerSpy, 'info', 'proxy ws reconnecting in 100 ms') + await pinoTest.once(loggerSpy, 'info', 'proxy ws reconnected') }) -*/ + test('should reconnect after failingwith retries', async (t) => { const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, reconnectOnClose: true } From 7dd97b913409efdfe880dab00924fff47cab5fc3 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Wed, 12 Feb 2025 16:13:42 +0100 Subject: [PATCH 08/25] wip --- README.md | 2 +- index.js | 24 +++--- test/ws-reconnect.js | 170 ++++++++++++++++++++++++++++++++----------- 3 files changed, 141 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 1afe340..fe8abd4 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ The default implementation forwards the `cookie` header. The `wsReconnect` option contains the configuration for the WebSocket reconnection feature; is an object with the following properties: - `pingInterval`: The interval between ping messages in ms (default: `30_000`). -- `maxReconnectionRetries`: The maximum number of reconnection attempts (default: `3`). +- `maxReconnectionRetries`: The maximum number of reconnection attempts (default: `3`). The counter is reset when the connection is established. - `reconnectInterval`: The interval between reconnection attempts in ms (default: `1_000`). - `reconnectDecay`: The decay factor for the reconnection interval (default: `1.5`). - `connectionTimeout`: The timeout for the connection in ms (default: `5_000`). diff --git a/index.js b/index.js index 2fab87f..440aad5 100644 --- a/index.js +++ b/index.js @@ -29,12 +29,12 @@ function liftErrorCode (code) { } function closeWebSocket (socket, code, reason) { + socket.isAlive = false if (socket.readyState === WebSocket.OPEN) { socket.close(liftErrorCode(code), reason) } } -// TODO timeout function waitConnection (socket, write) { if (socket.readyState === WebSocket.CONNECTING) { socket.once('open', write) @@ -43,7 +43,6 @@ function waitConnection (socket, write) { } } -// TODO timeout // TODO merge with waitConnection function waitForConnection (target, timeout) { return new Promise((resolve, reject) => { @@ -115,7 +114,6 @@ async function reconnect (logger, source, wsReconnectOptions, targetParams) { let target do { const reconnectWait = wsReconnectOptions.reconnectInterval * (wsReconnectOptions.reconnectDecay * attempts || 1) - logger.info({ target: targetParams.url, attempts }, `proxy ws reconnecting in ${reconnectWait} ms`) await wait(reconnectWait) try { @@ -129,7 +127,7 @@ async function reconnect (logger, source, wsReconnectOptions, targetParams) { } while (!target && attempts < wsReconnectOptions.maxReconnectionRetries) if (!target) { - logger.error({ target: targetParams.url, attempts }, 'proxy ws failed to reconnect') + logger.error({ target: targetParams.url, attempts }, 'proxy ws failed to reconnect! No more retries') return } @@ -143,10 +141,11 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe target.pingTimer && clearTimeout(source.pingTimer) target.pingTimer = undefined - // endless reconnect on close - // as long as the source connection is active + // reconnect target as long as the source connection is active if (source.isAlive && (target.broken || options.reconnectOnClose)) { target.isAlive = false + target.removeAllListeners() + // TODO source.removeAllListeners() reconnect(logger, source, options, targetParams) return } @@ -156,6 +155,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe closeWebSocket(target, code, reason) } + source.isAlive = true source.on('message', (data, binary) => { source.isAlive = true waitConnection(target, () => target.send(data, { binary })) @@ -170,16 +170,16 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe }) /* c8 ignore stop */ source.on('close', (code, reason) => { - logger.warn({ target: targetParams.url, code, reason }, 'proxy ws source close') + logger.warn({ target: targetParams.url, code, reason }, 'proxy ws source close event') close(code, reason) }) /* c8 ignore start */ source.on('error', error => { - logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws source error') + logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws source error event') close(1011, error.message) }) source.on('unexpected-response', () => { - logger.warn({ target: targetParams.url }, 'proxy ws source unexpected-response') + logger.warn({ target: targetParams.url }, 'proxy ws source unexpected-response event') close(1011, 'unexpected response') }) /* c8 ignore stop */ @@ -191,16 +191,16 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe /* c8 ignore stop */ target.on('pong', data => source.pong(data)) target.on('close', (code, reason) => { - logger.warn({ target: targetParams.url, code, reason }, 'proxy ws target close') + logger.warn({ target: targetParams.url, code, reason }, 'proxy ws target close event') close(code, reason) }) /* c8 ignore start */ target.on('error', error => { - logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws target error') + logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws target error event') close(1011, error.message) }) target.on('unexpected-response', () => { - logger.warn({ target: targetParams.url }, 'proxy ws target unexpected-response') + logger.warn({ target: targetParams.url }, 'proxy ws target unexpected-response event') close(1011, 'unexpected response') }) /* c8 ignore stop */ diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 0cb90da..3c577e5 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -11,7 +11,27 @@ const pinoTest = require('pino-test') const pino = require('pino') const proxyPlugin = require('../') -async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServerOptions }) { +function waitForLogMessage(loggerSpy, message, max = 100) { + return new Promise((resolve, reject) => { + let count = 0 + const fn = (received) => { + console.log(received) + + if (received.msg === message) { + loggerSpy.off('data', fn) + resolve() + } + count++ + if (count > max) { + loggerSpy.off('data', fn) + reject(new Error(`Max message count reached on waitForLogMessage: ${message}`)) + } + } + loggerSpy.on('data', fn) + }) +} + +async function createServices({ t, upstream, wsReconnectOptions, wsTargetOptions, wsServerOptions }) { const targetServer = createServer() const targetWs = new WebSocket.Server({ server: targetServer, ...wsTargetOptions }) @@ -21,7 +41,7 @@ async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServe const logger = pino(loggerSpy) const proxy = Fastify({ loggerInstance: logger }) proxy.register(proxyPlugin, { - upstream: `ws://127.0.0.1:${targetServer.address().port}`, + upstream: upstream || `ws://127.0.0.1:${targetServer.address().port}`, websocket: true, wsReconnect: wsReconnectOptions, wsServerOptions @@ -46,12 +66,11 @@ async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServe }, proxy, client, - loggerSpy + loggerSpy, + upstream } } - -// TODO use fake timers ? - +/* test('should use ping/pong to verify connection is alive - from source (server on proxy) to target', async (t) => { const wsReconnectOptions = { pingInterval: 100, reconnectInterval: 100, maxReconnectionRetries: 1 } @@ -70,64 +89,131 @@ test('should use ping/pong to verify connection is alive - from source (server o }) test('should reconnect on broken connection', async (t) => { - const wsReconnectOptions = { pingInterval: 250, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectDecay: 2 } + const wsReconnectOptions = { pingInterval: 500, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectDecay: 2 } const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) + let breakConnection = true target.ws.on('connection', async (socket) => { socket.on('ping', async () => { - // add latency to break the connection - await wait(500) + // add latency to break the connection once + if (breakConnection) { + await wait(wsReconnectOptions.pingInterval * 2) + breakConnection = false + } socket.pong() }) }) - await pinoTest.once(loggerSpy, 'warn', 'proxy ws connection is broken') - await pinoTest.once(loggerSpy, 'info', 'proxy ws reconnecting in 100 ms') - await pinoTest.once(loggerSpy, 'info', 'proxy ws reconnected') -}) + await waitForLogMessage(loggerSpy, 'proxy ws connection is broken') + await waitForLogMessage(loggerSpy, 'proxy ws target close event') + await waitForLogMessage(loggerSpy, 'proxy ws reconnected') + // TODO fix with source.removeAllListeners -test('should reconnect after failingwith retries', async (t) => { - const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, reconnectOnClose: true } + t.end() +}) - const { target, logger } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) +test('should not reconnect after max retries', async (t) => { + const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, maxReconnectionRetries: 1 } - const refuseNewConnections = false + const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) + + let breakConnection = true target.ws.on('connection', async (socket) => { socket.on('ping', async () => { - // add latency to break the connection - await wait(500) + // add latency to break the connection once + if (breakConnection) { + await wait(wsReconnectOptions.pingInterval * 2) + breakConnection = false + } socket.pong() }) }) + await waitForLogMessage(loggerSpy, 'proxy ws connection is broken') + + target.ws.close() + target.server.close() + + await waitForLogMessage(loggerSpy, 'proxy ws target close event') + await waitForLogMessage(loggerSpy, 'proxy ws reconnect error') + await waitForLogMessage(loggerSpy, 'proxy ws failed to reconnect! No more retries') + + t.end() +}) +*/ + +test('should not reconnect because of connection timeout', async (t) => { + const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, maxReconnectionRetries: 1, connectionTimeout: 100 } + + const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) + + let breakConnection = true + target.ws.on('upgrade', (request, socket, head) => { - if (refuseNewConnections) { - socket.destroy() - } + console.log('upgrade') }) - // TODO use pino-test - // await pinoTest.once(logger, 'warn', 'proxy ws connection is broken') - - // close the target server to fail new connections - // setTimeout(() => { - // refuseNewConnections = true - // setTimeout(() => { - // refuseNewConnections = false - // }, 500) - // }, 1000) - - // t.ok(logger._warn.find(l => l[1] === 'proxy ws connection is broken')) - // t.ok(logger._info.find(l => l[1] === 'proxy ws reconnecting in 100 ms')) - // t.ok(logger._error.find(l => l[1] === 'proxy ws reconnect error' && l[0].attempts === 1)) - // t.ok(logger._info.find(l => l[1] === 'proxy ws reconnected' && l[0].attempts === 2)) + target.ws.on('connection', async (socket) => { + socket.on('ping', async () => { + // add latency to break the connection once + if (breakConnection) { + await wait(wsReconnectOptions.pingInterval * 2) + breakConnection = false + } + socket.pong() + }) + }) + + await waitForLogMessage(loggerSpy, 'proxy ws connection is broken') + + target.ws.close() + target.server.close() + + await waitForLogMessage(loggerSpy, 'proxy ws target close event') + await waitForLogMessage(loggerSpy, 'proxy ws reconnect error') + await waitForLogMessage(loggerSpy, 'proxy ws failed to reconnect! No more retries') + + t.end() +}) + +// TODO reconnect regular close + +/* +test('should reconnect with retry', async (t) => { + const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, reconnectOnClose: true } + + const { target, loggerSpy, upstream } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) + + let breakConnection = true + + target.ws.on('connection', async (socket) => { + socket.on('ping', async () => { + // add latency to break the connection once + if (breakConnection) { + await wait(wsReconnectOptions.pingInterval * 2) + breakConnection = false + } + socket.pong() + }) + }) + + await waitForLogMessage(loggerSpy, 'proxy ws connection is broken') + + // recreate a new target with the same upstream + + target.ws.close() + target.server.close() + await createServices({ t, upstream, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) + + await waitForLogMessage(loggerSpy, 'proxy ws target close event') + await waitForLogMessage(loggerSpy, 'proxy ws reconnect error') + await waitForLogMessage(loggerSpy, 'proxy ws reconnected') + + t.end() }) +*/ -// TODO reconnect fails becase of timeout -// cant reconnect -// TODO reconnect on close/error/unexpected-response -// TODO reconnectOnClose ... on shutdown -// TODO check only socket to target +// TODO reconnectOnClose but close all on shutdown From b0a3dae8ed3887ed3385dd8e63a397ba9b07e383 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Wed, 12 Feb 2025 18:22:45 +0100 Subject: [PATCH 09/25] wip --- README.md | 10 ++++----- index.js | 13 ++++++++---- src/options.js | 8 +++---- test/options.js | 44 ++++++++++++++++++++++++++++++++++++-- test/ws-reconnect.js | 50 +++++++++++--------------------------------- 5 files changed, 71 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index fe8abd4..26129f7 100644 --- a/README.md +++ b/README.md @@ -232,15 +232,15 @@ The default implementation forwards the `cookie` header. The `wsReconnect` option contains the configuration for the WebSocket reconnection feature; is an object with the following properties: - `pingInterval`: The interval between ping messages in ms (default: `30_000`). -- `maxReconnectionRetries`: The maximum number of reconnection attempts (default: `3`). The counter is reset when the connection is established. +- `maxReconnectionRetries`: The maximum number of reconnection retries (`1` to `Infinity`, default: `Infinity`). - `reconnectInterval`: The interval between reconnection attempts in ms (default: `1_000`). - `reconnectDecay`: The decay factor for the reconnection interval (default: `1.5`). -- `connectionTimeout`: The timeout for the connection in ms (default: `5_000`). -- `reconnectOnClose`: Whether to reconnect on close, as long as the source connection is active (default: `false`). +- `connectionTimeout`: The timeout for establishing the connection in ms (default: `5_000`). +- `reconnectOnClose`: Whether to reconnect on close, as long as the connection from the related client to the proxy is active (default: `false`). - TODO logs option? -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); the mechanism is based on ping/pong messages. -It verifies the connection status from the service to the target. +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). +The connection is considered broken if the target does not respond to the ping messages or no data is received from the target. Example: diff --git a/index.js b/index.js index 440aad5..be7f25e 100644 --- a/index.js +++ b/index.js @@ -43,7 +43,6 @@ function waitConnection (socket, write) { } } -// TODO merge with waitConnection function waitForConnection (target, timeout) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { @@ -145,7 +144,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe if (source.isAlive && (target.broken || options.reconnectOnClose)) { target.isAlive = false target.removeAllListeners() - // TODO source.removeAllListeners() + // TODO FIXME! source.removeAllListeners() reconnect(logger, source, options, targetParams) return } @@ -185,11 +184,17 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe /* c8 ignore stop */ // source WebSocket is already connected because it is created by ws server - target.on('message', (data, binary) => source.send(data, { binary })) + target.on('message', (data, binary) => { + target.isAlive = true + source.send(data, { binary }) + }) /* c8 ignore start */ target.on('ping', data => source.ping(data)) /* c8 ignore stop */ - target.on('pong', data => source.pong(data)) + target.on('pong', data => { + target.isAlive = true + source.pong(data) + }) target.on('close', (code, reason) => { logger.warn({ target: targetParams.url, code, reason }, 'proxy ws target close event') close(code, reason) diff --git a/src/options.js b/src/options.js index fe10582..1aa8989 100644 --- a/src/options.js +++ b/src/options.js @@ -1,7 +1,6 @@ 'use strict' const DEFAULT_PING_INTERVAL = 30_000 -const DEFAULT_MAX_RECONNECT_ATTEMPTS = 3 const DEFAULT_MAX_RECONNECTION_RETRIES = Infinity const DEFAULT_RECONNECT_INTERVAL = 1_000 const DEFAULT_RECONNECT_DECAY = 1.5 @@ -21,10 +20,10 @@ function validateOptions (options) { } wsReconnect.pingInterval = wsReconnect.pingInterval ?? DEFAULT_PING_INTERVAL - if (wsReconnect.maxReconnectionRetries !== undefined && (typeof wsReconnect.maxReconnectionRetries !== 'number' || wsReconnect.maxReconnectionRetries < 0)) { - throw new Error('wsReconnect.maxReconnectionRetries must be a non-negative number') + if (wsReconnect.maxReconnectionRetries !== undefined && (typeof wsReconnect.maxReconnectionRetries !== 'number' || wsReconnect.maxReconnectionRetries < 1)) { + throw new Error('wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1') } - wsReconnect.maxReconnectionRetries = wsReconnect.maxReconnectionRetries ?? DEFAULT_MAX_RECONNECT_ATTEMPTS + wsReconnect.maxReconnectionRetries = wsReconnect.maxReconnectionRetries ?? DEFAULT_MAX_RECONNECTION_RETRIES if (wsReconnect.reconnectInterval !== undefined && (typeof wsReconnect.reconnectInterval !== 'number' || wsReconnect.reconnectInterval < 100)) { throw new Error('wsReconnect.reconnectInterval (ms) must be a number greater than or equal to 100') @@ -53,7 +52,6 @@ function validateOptions (options) { module.exports = { validateOptions, DEFAULT_PING_INTERVAL, - DEFAULT_MAX_RECONNECT_ATTEMPTS, DEFAULT_MAX_RECONNECTION_RETRIES, DEFAULT_RECONNECT_INTERVAL, DEFAULT_RECONNECT_DECAY, diff --git a/test/options.js b/test/options.js index ba70bb0..a851bc1 100644 --- a/test/options.js +++ b/test/options.js @@ -2,13 +2,53 @@ const { test } = require('tap') const { validateOptions } = require('../src/options') - +const { DEFAULT_PING_INTERVAL, DEFAULT_MAX_RECONNECTION_RETRIES, DEFAULT_RECONNECT_INTERVAL, DEFAULT_RECONNECT_DECAY, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_RECONNECT_ON_CLOSE } = require('../src/options') test('validateOptions', (t) => { const requiredOptions = { upstream: 'someUpstream' } - // TODO + t.throws(() => validateOptions({}), 'upstream must be specified') + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: -1 } }), 'wsReconnect.pingInterval must be a non-negative number') + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: '1' } }), 'wsReconnect.pingInterval must be a non-negative number') + t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: 1 } })) + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: 0 } }), 'wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1') + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: -1 } }), 'wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1') + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: '1' } }), 'wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1') + t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: 1 } })) + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: 0 } }), 'wsReconnect.reconnectInterval (ms) must be a number greater than or equal to 100') + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: -1 } }), 'wsReconnect.reconnectInterval (ms) must be a number greater than or equal to 100') + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: '1' } }), 'wsReconnect.reconnectInterval (ms) must be a number greater than or equal to 100') + t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: 100 } })) + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: 0 } }), 'wsReconnect.reconnectDecay must be a number greater than or equal to 1') + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: -1 } }), 'wsReconnect.reconnectDecay must be a number greater than or equal to 1') + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: '1' } }), 'wsReconnect.reconnectDecay must be a number greater than or equal to 1') + t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: 1 } })) + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: -1 } }), 'wsReconnect.connectionTimeout must be a non-negative number') + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: '1' } }), 'wsReconnect.connectionTimeout must be a non-negative number') + t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: 1 } })) + + t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: '1' } }), 'wsReconnect.reconnectOnClose must be a boolean') + t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: true } })) + + t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: 1, maxReconnectionRetries: 1, reconnectInterval: 100, reconnectDecay: 1, connectionTimeout: 1, reconnectOnClose: true } })) + + t.equal(validateOptions({ ...requiredOptions, wsReconnect: { } }), { + ...requiredOptions, + wsReconnect: { + pingInterval: DEFAULT_PING_INTERVAL, + maxReconnectionRetries: DEFAULT_MAX_RECONNECTION_RETRIES, + reconnectInterval: DEFAULT_RECONNECT_INTERVAL, + reconnectDecay: DEFAULT_RECONNECT_DECAY, + connectionTimeout: DEFAULT_CONNECTION_TIMEOUT, + reconnectOnClose: DEFAULT_RECONNECT_ON_CLOSE + } + }) t.end() }) diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 3c577e5..3b4c6b4 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -11,11 +11,11 @@ const pinoTest = require('pino-test') const pino = require('pino') const proxyPlugin = require('../') -function waitForLogMessage(loggerSpy, message, max = 100) { +function waitForLogMessage (loggerSpy, message, max = 100) { return new Promise((resolve, reject) => { let count = 0 const fn = (received) => { - console.log(received) + // console.log(received) if (received.msg === message) { loggerSpy.off('data', fn) @@ -31,7 +31,7 @@ function waitForLogMessage(loggerSpy, message, max = 100) { }) } -async function createServices({ t, upstream, wsReconnectOptions, wsTargetOptions, wsServerOptions }) { +async function createServices ({ t, upstream, wsReconnectOptions, wsTargetOptions, wsServerOptions }) { const targetServer = createServer() const targetWs = new WebSocket.Server({ server: targetServer, ...wsTargetOptions }) @@ -70,7 +70,7 @@ async function createServices({ t, upstream, wsReconnectOptions, wsTargetOptions upstream } } -/* + test('should use ping/pong to verify connection is alive - from source (server on proxy) to target', async (t) => { const wsReconnectOptions = { pingInterval: 100, reconnectInterval: 100, maxReconnectionRetries: 1 } @@ -110,8 +110,6 @@ test('should reconnect on broken connection', async (t) => { await waitForLogMessage(loggerSpy, 'proxy ws reconnected') // TODO fix with source.removeAllListeners - - t.end() }) test('should not reconnect after max retries', async (t) => { @@ -140,48 +138,28 @@ test('should not reconnect after max retries', async (t) => { await waitForLogMessage(loggerSpy, 'proxy ws target close event') await waitForLogMessage(loggerSpy, 'proxy ws reconnect error') await waitForLogMessage(loggerSpy, 'proxy ws failed to reconnect! No more retries') - - t.end() }) -*/ - -test('should not reconnect because of connection timeout', async (t) => { - const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, maxReconnectionRetries: 1, connectionTimeout: 100 } - - const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) - let breakConnection = true +test('should reconnect on regular target connection close', async (t) => { + const wsReconnectOptions = { pingInterval: 200, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectOnClose: false } - target.ws.on('upgrade', (request, socket, head) => { - console.log('upgrade') - }) + const { target, loggerSpy } = await createServices({ t, wsReconnectOptions }) target.ws.on('connection', async (socket) => { socket.on('ping', async () => { - // add latency to break the connection once - if (breakConnection) { - await wait(wsReconnectOptions.pingInterval * 2) - breakConnection = false - } socket.pong() }) - }) - - await waitForLogMessage(loggerSpy, 'proxy ws connection is broken') - target.ws.close() - target.server.close() + await wait(1_000) + socket.close() + }) await waitForLogMessage(loggerSpy, 'proxy ws target close event') - await waitForLogMessage(loggerSpy, 'proxy ws reconnect error') - await waitForLogMessage(loggerSpy, 'proxy ws failed to reconnect! No more retries') - - t.end() + await waitForLogMessage(loggerSpy, 'proxy ws close link') }) -// TODO reconnect regular close - /* +TODO fix! test('should reconnect with retry', async (t) => { const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, reconnectOnClose: true } @@ -211,9 +189,5 @@ test('should reconnect with retry', async (t) => { await waitForLogMessage(loggerSpy, 'proxy ws target close event') await waitForLogMessage(loggerSpy, 'proxy ws reconnect error') await waitForLogMessage(loggerSpy, 'proxy ws reconnected') - - t.end() }) */ - -// TODO reconnectOnClose but close all on shutdown From ae2a31bf3fdf4fb78eed5fb99105d877feecf0d5 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Wed, 12 Feb 2025 18:40:02 +0100 Subject: [PATCH 10/25] wip --- test/options.js | 52 ++++++++++++++++++++++---------------------- test/ws-reconnect.js | 9 ++++---- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/test/options.js b/test/options.js index a851bc1..a5ba799 100644 --- a/test/options.js +++ b/test/options.js @@ -1,44 +1,46 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') +const assert = require('node:assert') const { validateOptions } = require('../src/options') const { DEFAULT_PING_INTERVAL, DEFAULT_MAX_RECONNECTION_RETRIES, DEFAULT_RECONNECT_INTERVAL, DEFAULT_RECONNECT_DECAY, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_RECONNECT_ON_CLOSE } = require('../src/options') + test('validateOptions', (t) => { const requiredOptions = { upstream: 'someUpstream' } - t.throws(() => validateOptions({}), 'upstream must be specified') + assert.throws(() => validateOptions({}), /upstream must be specified/) - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: -1 } }), 'wsReconnect.pingInterval must be a non-negative number') - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: '1' } }), 'wsReconnect.pingInterval must be a non-negative number') - t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: 1 } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: -1 } }), /wsReconnect.pingInterval must be a non-negative number/) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: '1' } }), /wsReconnect.pingInterval must be a non-negative number/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: 1 } })) - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: 0 } }), 'wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1') - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: -1 } }), 'wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1') - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: '1' } }), 'wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1') - t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: 1 } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: 0 } }), /wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1/) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: -1 } }), /wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1/) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: '1' } }), /wsReconnect.maxReconnectionRetries must be a number greater than or equal to 1/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { maxReconnectionRetries: 1 } })) - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: 0 } }), 'wsReconnect.reconnectInterval (ms) must be a number greater than or equal to 100') - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: -1 } }), 'wsReconnect.reconnectInterval (ms) must be a number greater than or equal to 100') - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: '1' } }), 'wsReconnect.reconnectInterval (ms) must be a number greater than or equal to 100') - t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: 100 } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: 0 } }), /wsReconnect.reconnectInterval \(ms\) must be a number greater than or equal to 100/) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: -1 } }), /wsReconnect.reconnectInterval \(ms\) must be a number greater than or equal to 100/) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: '1' } }), /wsReconnect.reconnectInterval \(ms\) must be a number greater than or equal to 100/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectInterval: 100 } })) - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: 0 } }), 'wsReconnect.reconnectDecay must be a number greater than or equal to 1') - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: -1 } }), 'wsReconnect.reconnectDecay must be a number greater than or equal to 1') - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: '1' } }), 'wsReconnect.reconnectDecay must be a number greater than or equal to 1') - t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: 1 } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: 0 } }), /wsReconnect.reconnectDecay must be a number greater than or equal to 1/) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: -1 } }), /wsReconnect.reconnectDecay must be a number greater than or equal to 1/) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: '1' } }), /wsReconnect.reconnectDecay must be a number greater than or equal to 1/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectDecay: 1 } })) - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: -1 } }), 'wsReconnect.connectionTimeout must be a non-negative number') - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: '1' } }), 'wsReconnect.connectionTimeout must be a non-negative number') - t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: 1 } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: -1 } }), /wsReconnect.connectionTimeout must be a non-negative number/) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: '1' } }), /wsReconnect.connectionTimeout must be a non-negative number/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { connectionTimeout: 1 } })) - t.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: '1' } }), 'wsReconnect.reconnectOnClose must be a boolean') - t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: true } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: '1' } }), /wsReconnect.reconnectOnClose must be a boolean/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: true } })) - t.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: 1, maxReconnectionRetries: 1, reconnectInterval: 100, reconnectDecay: 1, connectionTimeout: 1, reconnectOnClose: true } })) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: 1, maxReconnectionRetries: 1, reconnectInterval: 100, reconnectDecay: 1, connectionTimeout: 1, reconnectOnClose: true } })) - t.equal(validateOptions({ ...requiredOptions, wsReconnect: { } }), { + assert.deepEqual(validateOptions({ ...requiredOptions, wsReconnect: { } }), { ...requiredOptions, wsReconnect: { pingInterval: DEFAULT_PING_INTERVAL, @@ -49,6 +51,4 @@ test('validateOptions', (t) => { reconnectOnClose: DEFAULT_RECONNECT_ON_CLOSE } }) - - t.end() }) diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 3b4c6b4..9f049b6 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -1,10 +1,11 @@ 'use strict' +const { test } = require('node:test') +const assert = require('node:assert') const { createServer } = require('node:http') const { promisify } = require('node:util') const { once } = require('node:events') const { setTimeout: wait } = require('node:timers/promises') -const { test } = require('tap') const Fastify = require('fastify') const WebSocket = require('ws') const pinoTest = require('pino-test') @@ -52,7 +53,7 @@ async function createServices ({ t, upstream, wsReconnectOptions, wsTargetOption const client = new WebSocket(`ws://127.0.0.1:${proxy.server.address().port}`) await once(client, 'open') - t.teardown(async () => { + t.after(async () => { client.close() targetWs.close() targetServer.close() @@ -85,7 +86,7 @@ test('should use ping/pong to verify connection is alive - from source (server o await wait(250) - t.ok(counter > 0) + assert.ok(counter > 0) }) test('should reconnect on broken connection', async (t) => { @@ -108,8 +109,6 @@ test('should reconnect on broken connection', async (t) => { await waitForLogMessage(loggerSpy, 'proxy ws connection is broken') await waitForLogMessage(loggerSpy, 'proxy ws target close event') await waitForLogMessage(loggerSpy, 'proxy ws reconnected') - - // TODO fix with source.removeAllListeners }) test('should not reconnect after max retries', async (t) => { From 8f9501fd2639d2b317a315f3eec5a6593e556dd4 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Thu, 13 Feb 2025 10:05:04 +0100 Subject: [PATCH 11/25] wip --- README.md | 2 +- index.js | 82 +++++++++++++++++++++----------------------- src/options.js | 9 ++++- test/options.js | 12 +++++-- test/ws-reconnect.js | 8 ++--- 5 files changed, 61 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 26129f7..e06d490 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ The `wsReconnect` option contains the configuration for the WebSocket reconnecti - `reconnectDecay`: The decay factor for the reconnection interval (default: `1.5`). - `connectionTimeout`: The timeout for establishing the connection in ms (default: `5_000`). - `reconnectOnClose`: Whether to reconnect on close, as long as the connection from the related client to the proxy is active (default: `false`). -- TODO logs option? +- `logs`: Whether to log the reconnection process (default: `false`). 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). The connection is considered broken if the target does not respond to the ping messages or no data is received from the target. diff --git a/index.js b/index.js index be7f25e..5f7e60d 100644 --- a/index.js +++ b/index.js @@ -14,7 +14,7 @@ const kWs = Symbol('ws') const kWsHead = Symbol('wsHead') const kWsUpgradeListener = Symbol('wsUpgradeListener') -function liftErrorCode (code) { +function liftErrorCode(code) { /* c8 ignore start */ if (typeof code !== 'number') { // Sometimes "close" event emits with a non-numeric value @@ -28,14 +28,14 @@ function liftErrorCode (code) { /* c8 ignore stop */ } -function closeWebSocket (socket, code, reason) { +function closeWebSocket(socket, code, reason) { socket.isAlive = false if (socket.readyState === WebSocket.OPEN) { socket.close(liftErrorCode(code), reason) } } -function waitConnection (socket, write) { +function waitConnection(socket, write) { if (socket.readyState === WebSocket.CONNECTING) { socket.once('open', write) } else { @@ -43,7 +43,7 @@ function waitConnection (socket, write) { } } -function waitForConnection (target, timeout) { +function waitForConnection(target, timeout) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error('WebSocket connection timeout')) @@ -70,14 +70,14 @@ function waitForConnection (target, timeout) { }) } -function isExternalUrl (url) { +function isExternalUrl(url) { return urlPattern.test(url) } -function noop () { } +function noop() { } -function proxyWebSockets (source, target) { - function close (code, reason) { +function proxyWebSockets(source, target) { + function close(code, reason) { closeWebSocket(source, code, reason) closeWebSocket(target, code, reason) } @@ -106,7 +106,7 @@ function proxyWebSockets (source, target) { /* c8 ignore stop */ } -async function reconnect (logger, source, wsReconnectOptions, targetParams) { +async function reconnect(logger, source, wsReconnectOptions, targetParams) { const { url, subprotocols, optionsWs } = targetParams let attempts = 0 @@ -119,7 +119,7 @@ async function reconnect (logger, source, wsReconnectOptions, targetParams) { target = new WebSocket(url, subprotocols, optionsWs) await waitForConnection(target, wsReconnectOptions.connectionTimeout) } catch (err) { - logger.error({ target: targetParams.url, err, attempts }, 'proxy ws reconnect error') + wsReconnectOptions.logs && logger.error({ target: targetParams.url, err, attempts }, 'proxy ws reconnect error') attempts++ target = undefined } @@ -130,13 +130,12 @@ async function reconnect (logger, source, wsReconnectOptions, targetParams) { return } - logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected') + wsReconnectOptions.logs && logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected') proxyWebSocketsWithReconnection(logger, source, target, wsReconnectOptions, targetParams) } -// source is alive since it is created by the proxy service -function proxyWebSocketsWithReconnection (logger, source, target, options, targetParams) { - function close (code, reason) { +function proxyWebSocketsWithReconnection(logger, source, target, options, targetParams, fromReconnection = false) { + function close(code, reason) { target.pingTimer && clearTimeout(source.pingTimer) target.pingTimer = undefined @@ -149,17 +148,17 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe return } - logger.info({ msg: 'proxy ws close link' }) + options.logs && logger.info({ msg: 'proxy ws close link' }) closeWebSocket(source, code, reason) closeWebSocket(target, code, reason) } + // source is alive since it is created by the proxy service source.isAlive = true source.on('message', (data, binary) => { source.isAlive = true waitConnection(target, () => target.send(data, { binary })) }) - /* c8 ignore start */ source.on('ping', data => { waitConnection(target, () => target.ping(data)) }) @@ -167,54 +166,51 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe source.isAlive = true waitConnection(target, () => target.pong(data)) }) - /* c8 ignore stop */ source.on('close', (code, reason) => { - logger.warn({ target: targetParams.url, code, reason }, 'proxy ws source close event') + options.logs && logger.warn({ target: targetParams.url, code, reason }, 'proxy ws source close event') close(code, reason) }) - /* c8 ignore start */ source.on('error', error => { - logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws source error event') + options.logs && logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws source error event') close(1011, error.message) }) source.on('unexpected-response', () => { - logger.warn({ target: targetParams.url }, 'proxy ws source unexpected-response event') + options.logs && logger.warn({ target: targetParams.url }, 'proxy ws source unexpected-response event') close(1011, 'unexpected response') }) - /* c8 ignore stop */ // source WebSocket is already connected because it is created by ws server target.on('message', (data, binary) => { target.isAlive = true source.send(data, { binary }) }) - /* c8 ignore start */ - target.on('ping', data => source.ping(data)) - /* c8 ignore stop */ + target.on('ping', data => { + target.isAlive = true + source.ping(data) + }) target.on('pong', data => { target.isAlive = true source.pong(data) }) target.on('close', (code, reason) => { - logger.warn({ target: targetParams.url, code, reason }, 'proxy ws target close event') + options.logs && logger.warn({ target: targetParams.url, code, reason }, 'proxy ws target close event') close(code, reason) }) - /* c8 ignore start */ + target.on('error', error => { - logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws target error event') + options.logs && logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws target error event') close(1011, error.message) }) target.on('unexpected-response', () => { - logger.warn({ target: targetParams.url }, 'proxy ws target unexpected-response event') + options.logs && logger.warn({ target: targetParams.url }, 'proxy ws target unexpected-response event') close(1011, 'unexpected response') }) - /* c8 ignore stop */ target.isAlive = true target.pingTimer = setInterval(() => { if (target.isAlive === false) { target.broken = true - logger.warn({ target: targetParams.url }, 'proxy ws connection is broken') + options.logs && logger.warn({ target: targetParams.url }, 'proxy ws connection is broken') target.pingTimer && clearInterval(target.pingTimer) target.pingTimer = undefined return target.terminate() @@ -224,7 +220,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe }, options.pingInterval).unref() } -function handleUpgrade (fastify, rawRequest, socket, head) { +function handleUpgrade(fastify, rawRequest, socket, head) { // Save a reference to the socket and then dispatch the request through the normal fastify router so that it will invoke hooks and then eventually a route handler that might upgrade the socket. rawRequest[kWs] = socket rawRequest[kWsHead] = head @@ -239,7 +235,7 @@ function handleUpgrade (fastify, rawRequest, socket, head) { } class WebSocketProxy { - constructor (fastify, { wsReconnect, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { + constructor(fastify, { wsReconnect, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { this.logger = fastify.log this.wsClientOptions = { rewriteRequestHeaders: defaultWsHeadersRewrite, @@ -298,7 +294,7 @@ class WebSocketProxy { this.prefixList = [] } - findUpstream (request, dest) { + findUpstream(request, dest) { const { search } = new URL(request.url, 'ws://127.0.0.1') if (typeof this.wsUpstream === 'string' && this.wsUpstream !== '') { @@ -321,7 +317,7 @@ class WebSocketProxy { return target } - handleConnection (source, request, dest) { + handleConnection(source, request, dest) { const url = this.findUpstream(request, dest) const queryString = getQueryString(url.search, request.url, this.wsClientOptions, request) url.search = queryString @@ -349,7 +345,7 @@ class WebSocketProxy { } } -function getQueryString (search, reqUrl, opts, request) { +function getQueryString(search, reqUrl, opts, request) { if (typeof opts.queryString === 'function') { return '?' + opts.queryString(search, reqUrl, request) } @@ -365,14 +361,14 @@ function getQueryString (search, reqUrl, opts, request) { return '' } -function defaultWsHeadersRewrite (headers, request) { +function defaultWsHeadersRewrite(headers, request) { if (request.headers.cookie) { return { ...headers, cookie: request.headers.cookie } } return { ...headers } } -function generateRewritePrefix (prefix, opts) { +function generateRewritePrefix(prefix, opts) { let rewritePrefix = opts.rewritePrefix || (opts.upstream ? new URL(opts.upstream).pathname : '/') if (!prefix.endsWith('/') && rewritePrefix.endsWith('/')) { @@ -382,7 +378,7 @@ function generateRewritePrefix (prefix, opts) { return rewritePrefix } -async function fastifyHttpProxy (fastify, opts) { +async function fastifyHttpProxy(fastify, opts) { opts = validateOptions(opts) const preHandler = opts.preHandler || opts.beforeHandler @@ -408,7 +404,7 @@ async function fastifyHttpProxy (fastify, opts) { fastify.addContentTypeParser('*', bodyParser) } - function rewriteHeaders (headers, req) { + function rewriteHeaders(headers, req) { const location = headers.location if (location && !isExternalUrl(location) && internalRewriteLocationHeader) { headers.location = location.replace(rewritePrefix, fastify.prefix) @@ -419,7 +415,7 @@ async function fastifyHttpProxy (fastify, opts) { return headers } - function bodyParser (_req, payload, done) { + function bodyParser(_req, payload, done) { done(null, payload) } @@ -446,7 +442,7 @@ async function fastifyHttpProxy (fastify, opts) { wsProxy = new WebSocketProxy(fastify, opts) } - function extractUrlComponents (urlString) { + function extractUrlComponents(urlString) { const [path, queryString] = urlString.split('?', 2) const components = { path, @@ -460,7 +456,7 @@ async function fastifyHttpProxy (fastify, opts) { return components } - function handler (request, reply) { + function handler(request, reply) { const { path, queryParams } = extractUrlComponents(request.url) let dest = path diff --git a/src/options.js b/src/options.js index 1aa8989..d364400 100644 --- a/src/options.js +++ b/src/options.js @@ -6,6 +6,7 @@ const DEFAULT_RECONNECT_INTERVAL = 1_000 const DEFAULT_RECONNECT_DECAY = 1.5 const DEFAULT_CONNECTION_TIMEOUT = 5_000 const DEFAULT_RECONNECT_ON_CLOSE = false +const DEFAULT_LOGS = false function validateOptions (options) { if (!options.upstream && !options.websocket && !((options.upstream === '' || options.wsUpstream === '') && options.replyOptions && typeof options.replyOptions.getUpstream === 'function')) { @@ -44,6 +45,11 @@ function validateOptions (options) { throw new Error('wsReconnect.reconnectOnClose must be a boolean') } wsReconnect.reconnectOnClose = wsReconnect.reconnectOnClose ?? DEFAULT_RECONNECT_ON_CLOSE + + if (wsReconnect.logs !== undefined && typeof wsReconnect.logs !== 'boolean') { + throw new Error('wsReconnect.logs must be a boolean') + } + wsReconnect.logs = wsReconnect.logs ?? DEFAULT_LOGS } return options @@ -56,5 +62,6 @@ module.exports = { DEFAULT_RECONNECT_INTERVAL, DEFAULT_RECONNECT_DECAY, DEFAULT_CONNECTION_TIMEOUT, - DEFAULT_RECONNECT_ON_CLOSE + DEFAULT_RECONNECT_ON_CLOSE, + DEFAULT_LOGS } diff --git a/test/options.js b/test/options.js index a5ba799..f925abb 100644 --- a/test/options.js +++ b/test/options.js @@ -3,7 +3,7 @@ const { test } = require('node:test') const assert = require('node:assert') const { validateOptions } = require('../src/options') -const { DEFAULT_PING_INTERVAL, DEFAULT_MAX_RECONNECTION_RETRIES, DEFAULT_RECONNECT_INTERVAL, DEFAULT_RECONNECT_DECAY, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_RECONNECT_ON_CLOSE } = require('../src/options') +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') test('validateOptions', (t) => { const requiredOptions = { @@ -38,8 +38,13 @@ test('validateOptions', (t) => { assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: '1' } }), /wsReconnect.reconnectOnClose must be a boolean/) assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { reconnectOnClose: true } })) - assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: 1, maxReconnectionRetries: 1, reconnectInterval: 100, reconnectDecay: 1, connectionTimeout: 1, reconnectOnClose: true } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { logs: '1' } }), /wsReconnect.logs must be a boolean/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { logs: true } })) + // set all values + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: 1, maxReconnectionRetries: 1, reconnectInterval: 100, reconnectDecay: 1, connectionTimeout: 1, reconnectOnClose: true, logs: true } })) + + // get default values assert.deepEqual(validateOptions({ ...requiredOptions, wsReconnect: { } }), { ...requiredOptions, wsReconnect: { @@ -48,7 +53,8 @@ test('validateOptions', (t) => { reconnectInterval: DEFAULT_RECONNECT_INTERVAL, reconnectDecay: DEFAULT_RECONNECT_DECAY, connectionTimeout: DEFAULT_CONNECTION_TIMEOUT, - reconnectOnClose: DEFAULT_RECONNECT_ON_CLOSE + reconnectOnClose: DEFAULT_RECONNECT_ON_CLOSE, + logs: DEFAULT_LOGS } }) }) diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 9f049b6..558cd02 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -90,7 +90,7 @@ test('should use ping/pong to verify connection is alive - from source (server o }) test('should reconnect on broken connection', async (t) => { - const wsReconnectOptions = { pingInterval: 500, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectDecay: 2 } + const wsReconnectOptions = { pingInterval: 500, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectDecay: 2, logs: true } const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) @@ -112,7 +112,7 @@ test('should reconnect on broken connection', async (t) => { }) test('should not reconnect after max retries', async (t) => { - const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, maxReconnectionRetries: 1 } + const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, maxReconnectionRetries: 1, logs: true } const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) @@ -140,7 +140,7 @@ test('should not reconnect after max retries', async (t) => { }) test('should reconnect on regular target connection close', async (t) => { - const wsReconnectOptions = { pingInterval: 200, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectOnClose: false } + const wsReconnectOptions = { pingInterval: 200, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectOnClose: false, logs: true } const { target, loggerSpy } = await createServices({ t, wsReconnectOptions }) @@ -158,7 +158,7 @@ test('should reconnect on regular target connection close', async (t) => { }) /* -TODO fix! +TODO fix test('should reconnect with retry', async (t) => { const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, reconnectOnClose: true } From 01d02b187506131912c8b18ce7248515ad651b29 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Thu, 13 Feb 2025 10:30:12 +0100 Subject: [PATCH 12/25] wip --- README.md | 6 --- index.js | 94 ++++++++++++++++++++++++++------------------ test/ws-reconnect.js | 8 ++-- 3 files changed, 61 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index e06d490..f7bd3ae 100644 --- a/README.md +++ b/README.md @@ -242,12 +242,6 @@ The `wsReconnect` option contains the configuration for the WebSocket reconnecti 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). The connection is considered broken if the target does not respond to the ping messages or no data is received from the target. -Example: - -```js -TODO -``` - ## Benchmarks The following benchmarks were generated on a dedicated server with an Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz and 64GB of RAM: diff --git a/index.js b/index.js index 5f7e60d..e41f91e 100644 --- a/index.js +++ b/index.js @@ -14,7 +14,7 @@ const kWs = Symbol('ws') const kWsHead = Symbol('wsHead') const kWsUpgradeListener = Symbol('wsUpgradeListener') -function liftErrorCode(code) { +function liftErrorCode (code) { /* c8 ignore start */ if (typeof code !== 'number') { // Sometimes "close" event emits with a non-numeric value @@ -28,14 +28,14 @@ function liftErrorCode(code) { /* c8 ignore stop */ } -function closeWebSocket(socket, code, reason) { +function closeWebSocket (socket, code, reason) { socket.isAlive = false if (socket.readyState === WebSocket.OPEN) { socket.close(liftErrorCode(code), reason) } } -function waitConnection(socket, write) { +function waitConnection (socket, write) { if (socket.readyState === WebSocket.CONNECTING) { socket.once('open', write) } else { @@ -43,7 +43,7 @@ function waitConnection(socket, write) { } } -function waitForConnection(target, timeout) { +function waitForConnection (target, timeout) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { reject(new Error('WebSocket connection timeout')) @@ -70,14 +70,14 @@ function waitForConnection(target, timeout) { }) } -function isExternalUrl(url) { +function isExternalUrl (url) { return urlPattern.test(url) } -function noop() { } +function noop () { } -function proxyWebSockets(source, target) { - function close(code, reason) { +function proxyWebSockets (source, target) { + function close (code, reason) { closeWebSocket(source, code, reason) closeWebSocket(target, code, reason) } @@ -106,7 +106,7 @@ function proxyWebSockets(source, target) { /* c8 ignore stop */ } -async function reconnect(logger, source, wsReconnectOptions, targetParams) { +async function reconnect (logger, source, wsReconnectOptions, targetParams) { const { url, subprotocols, optionsWs } = targetParams let attempts = 0 @@ -134,8 +134,8 @@ async function reconnect(logger, source, wsReconnectOptions, targetParams) { proxyWebSocketsWithReconnection(logger, source, target, wsReconnectOptions, targetParams) } -function proxyWebSocketsWithReconnection(logger, source, target, options, targetParams, fromReconnection = false) { - function close(code, reason) { +function proxyWebSocketsWithReconnection (logger, source, target, options, targetParams, fromReconnection = false) { + function close (code, reason) { target.pingTimer && clearTimeout(source.pingTimer) target.pingTimer = undefined @@ -143,7 +143,8 @@ function proxyWebSocketsWithReconnection(logger, source, target, options, target if (source.isAlive && (target.broken || options.reconnectOnClose)) { target.isAlive = false target.removeAllListeners() - // TODO FIXME! source.removeAllListeners() + // need to specify the listeners to remove + removeSourceListeners(source) reconnect(logger, source, options, targetParams) return } @@ -153,31 +154,48 @@ function proxyWebSocketsWithReconnection(logger, source, target, options, target closeWebSocket(target, code, reason) } - // source is alive since it is created by the proxy service - source.isAlive = true - source.on('message', (data, binary) => { + function removeSourceListeners (source) { + source.off('message', sourceOnMessage) + source.off('ping', sourceOnPing) + source.off('pong', sourceOnPong) + source.off('close', sourceOnClose) + source.off('error', sourceOnError) + source.off('unexpected-response', sourceOnUnexpectedResponse) + } + + function sourceOnMessage (data, binary) { source.isAlive = true waitConnection(target, () => target.send(data, { binary })) - }) - source.on('ping', data => { + } + function sourceOnPing (data) { waitConnection(target, () => target.ping(data)) - }) - source.on('pong', data => { + } + function sourceOnPong (data) { source.isAlive = true waitConnection(target, () => target.pong(data)) - }) - source.on('close', (code, reason) => { + } + function sourceOnClose (code, reason) { options.logs && logger.warn({ target: targetParams.url, code, reason }, 'proxy ws source close event') close(code, reason) - }) - source.on('error', error => { + } + function sourceOnError (error) { options.logs && logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws source error event') close(1011, error.message) - }) - source.on('unexpected-response', () => { + } + function sourceOnUnexpectedResponse () { options.logs && logger.warn({ target: targetParams.url }, 'proxy ws source unexpected-response event') close(1011, 'unexpected response') - }) + } + + // source is alive since it is created by the proxy service + // the pinger is not set since we can't reconnect from here + source.isAlive = true + source.on('message', sourceOnMessage) + source.on('ping', sourceOnPing) + source.on('pong', sourceOnPong) + source.on('close', sourceOnClose) + source.on('error', sourceOnError) + source.on('unexpected-response', sourceOnUnexpectedResponse) // source WebSocket is already connected because it is created by ws server target.on('message', (data, binary) => { @@ -220,7 +238,7 @@ function proxyWebSocketsWithReconnection(logger, source, target, options, target }, options.pingInterval).unref() } -function handleUpgrade(fastify, rawRequest, socket, head) { +function handleUpgrade (fastify, rawRequest, socket, head) { // Save a reference to the socket and then dispatch the request through the normal fastify router so that it will invoke hooks and then eventually a route handler that might upgrade the socket. rawRequest[kWs] = socket rawRequest[kWsHead] = head @@ -235,7 +253,7 @@ function handleUpgrade(fastify, rawRequest, socket, head) { } class WebSocketProxy { - constructor(fastify, { wsReconnect, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { + constructor (fastify, { wsReconnect, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { this.logger = fastify.log this.wsClientOptions = { rewriteRequestHeaders: defaultWsHeadersRewrite, @@ -294,7 +312,7 @@ class WebSocketProxy { this.prefixList = [] } - findUpstream(request, dest) { + findUpstream (request, dest) { const { search } = new URL(request.url, 'ws://127.0.0.1') if (typeof this.wsUpstream === 'string' && this.wsUpstream !== '') { @@ -317,7 +335,7 @@ class WebSocketProxy { return target } - handleConnection(source, request, dest) { + handleConnection (source, request, dest) { const url = this.findUpstream(request, dest) const queryString = getQueryString(url.search, request.url, this.wsClientOptions, request) url.search = queryString @@ -345,7 +363,7 @@ class WebSocketProxy { } } -function getQueryString(search, reqUrl, opts, request) { +function getQueryString (search, reqUrl, opts, request) { if (typeof opts.queryString === 'function') { return '?' + opts.queryString(search, reqUrl, request) } @@ -361,14 +379,14 @@ function getQueryString(search, reqUrl, opts, request) { return '' } -function defaultWsHeadersRewrite(headers, request) { +function defaultWsHeadersRewrite (headers, request) { if (request.headers.cookie) { return { ...headers, cookie: request.headers.cookie } } return { ...headers } } -function generateRewritePrefix(prefix, opts) { +function generateRewritePrefix (prefix, opts) { let rewritePrefix = opts.rewritePrefix || (opts.upstream ? new URL(opts.upstream).pathname : '/') if (!prefix.endsWith('/') && rewritePrefix.endsWith('/')) { @@ -378,7 +396,7 @@ function generateRewritePrefix(prefix, opts) { return rewritePrefix } -async function fastifyHttpProxy(fastify, opts) { +async function fastifyHttpProxy (fastify, opts) { opts = validateOptions(opts) const preHandler = opts.preHandler || opts.beforeHandler @@ -404,7 +422,7 @@ async function fastifyHttpProxy(fastify, opts) { fastify.addContentTypeParser('*', bodyParser) } - function rewriteHeaders(headers, req) { + function rewriteHeaders (headers, req) { const location = headers.location if (location && !isExternalUrl(location) && internalRewriteLocationHeader) { headers.location = location.replace(rewritePrefix, fastify.prefix) @@ -415,7 +433,7 @@ async function fastifyHttpProxy(fastify, opts) { return headers } - function bodyParser(_req, payload, done) { + function bodyParser (_req, payload, done) { done(null, payload) } @@ -442,7 +460,7 @@ async function fastifyHttpProxy(fastify, opts) { wsProxy = new WebSocketProxy(fastify, opts) } - function extractUrlComponents(urlString) { + function extractUrlComponents (urlString) { const [path, queryString] = urlString.split('?', 2) const components = { path, @@ -456,7 +474,7 @@ async function fastifyHttpProxy(fastify, opts) { return components } - function handler(request, reply) { + function handler (request, reply) { const { path, queryParams } = extractUrlComponents(request.url) let dest = path diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 558cd02..779392b 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -16,7 +16,7 @@ function waitForLogMessage (loggerSpy, message, max = 100) { return new Promise((resolve, reject) => { let count = 0 const fn = (received) => { - // console.log(received) + console.log(received) if (received.msg === message) { loggerSpy.off('data', fn) @@ -94,18 +94,20 @@ test('should reconnect on broken connection', async (t) => { const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) - let breakConnection = true + const breakConnection = true target.ws.on('connection', async (socket) => { socket.on('ping', async () => { // add latency to break the connection once if (breakConnection) { await wait(wsReconnectOptions.pingInterval * 2) - breakConnection = false + // breakConnection = false } socket.pong() }) }) + await wait(10_000) + await waitForLogMessage(loggerSpy, 'proxy ws connection is broken') await waitForLogMessage(loggerSpy, 'proxy ws target close event') await waitForLogMessage(loggerSpy, 'proxy ws reconnected') From e13f2fd38e908a9cb625e26d5132730aa404d452 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Thu, 13 Feb 2025 10:30:35 +0100 Subject: [PATCH 13/25] wip --- test/ws-reconnect.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 779392b..2563d12 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -106,8 +106,6 @@ test('should reconnect on broken connection', async (t) => { }) }) - await wait(10_000) - await waitForLogMessage(loggerSpy, 'proxy ws connection is broken') await waitForLogMessage(loggerSpy, 'proxy ws target close event') await waitForLogMessage(loggerSpy, 'proxy ws reconnected') From ac1225a315de7a6d372da340aa87c74bddb74a3a Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Thu, 13 Feb 2025 11:33:42 +0100 Subject: [PATCH 14/25] feat: websocket reconnection --- index.js | 14 ++++++++++++- test/ws-reconnect.js | 47 +++++++++++++++++++++++++------------------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/index.js b/index.js index e41f91e..5627663 100644 --- a/index.js +++ b/index.js @@ -46,13 +46,17 @@ function waitConnection (socket, write) { function waitForConnection (target, timeout) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { + /* c8 ignore start */ reject(new Error('WebSocket connection timeout')) + /* c8 ignore stop */ }, timeout) + /* c8 ignore start */ if (target.readyState === WebSocket.OPEN) { clearTimeout(timeoutId) return resolve() } + /* c8 ignore stop */ if (target.readyState === WebSocket.CONNECTING) { target.once('open', () => { @@ -63,10 +67,12 @@ function waitForConnection (target, timeout) { clearTimeout(timeoutId) reject(err) }) + /* c8 ignore start */ } else { clearTimeout(timeoutId) reject(new Error('WebSocket is closed')) } + /* c8 ignore stop */ }) } @@ -113,6 +119,7 @@ async function reconnect (logger, source, wsReconnectOptions, targetParams) { let target do { const reconnectWait = wsReconnectOptions.reconnectInterval * (wsReconnectOptions.reconnectDecay * attempts || 1) + wsReconnectOptions.logs && logger.warn({ target: targetParams.url }, `proxy ws reconnect in ${reconnectWait} ms`) await wait(reconnectWait) try { @@ -163,6 +170,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe source.off('unexpected-response', sourceOnUnexpectedResponse) } + /* c8 ignore start */ function sourceOnMessage (data, binary) { source.isAlive = true waitConnection(target, () => target.send(data, { binary })) @@ -186,6 +194,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe options.logs && logger.warn({ target: targetParams.url }, 'proxy ws source unexpected-response event') close(1011, 'unexpected response') } + /* c8 ignore stop */ // source is alive since it is created by the proxy service // the pinger is not set since we can't reconnect from here @@ -198,6 +207,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe source.on('unexpected-response', sourceOnUnexpectedResponse) // source WebSocket is already connected because it is created by ws server + /* c8 ignore start */ target.on('message', (data, binary) => { target.isAlive = true source.send(data, { binary }) @@ -210,11 +220,12 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe target.isAlive = true source.pong(data) }) + /* c8 ignore stop */ target.on('close', (code, reason) => { options.logs && logger.warn({ target: targetParams.url, code, reason }, 'proxy ws target close event') close(code, reason) }) - + /* c8 ignore start */ target.on('error', error => { options.logs && logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws target error event') close(1011, error.message) @@ -223,6 +234,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe options.logs && logger.warn({ target: targetParams.url }, 'proxy ws target unexpected-response event') close(1011, 'unexpected response') }) + /* c8 ignore stop */ target.isAlive = true target.pingTimer = setInterval(() => { diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 2563d12..ec9017a 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -16,8 +16,6 @@ function waitForLogMessage (loggerSpy, message, max = 100) { return new Promise((resolve, reject) => { let count = 0 const fn = (received) => { - console.log(received) - if (received.msg === message) { loggerSpy.off('data', fn) resolve() @@ -32,17 +30,27 @@ function waitForLogMessage (loggerSpy, message, max = 100) { }) } -async function createServices ({ t, upstream, wsReconnectOptions, wsTargetOptions, wsServerOptions }) { +async function createTargetServer (t, wsTargetOptions, port = 0) { const targetServer = createServer() const targetWs = new WebSocket.Server({ server: targetServer, ...wsTargetOptions }) + await promisify(targetServer.listen.bind(targetServer))({ port, host: '127.0.0.1' }) + + t.after(() => { + targetWs.close() + targetServer.close() + }) + + return { targetServer, targetWs } +} - await promisify(targetServer.listen.bind(targetServer))({ port: 0, host: '127.0.0.1' }) +async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServerOptions, targetPort = 0 }) { + const { targetServer, targetWs } = await createTargetServer(t, wsTargetOptions, targetPort) const loggerSpy = pinoTest.sink() const logger = pino(loggerSpy) const proxy = Fastify({ loggerInstance: logger }) proxy.register(proxyPlugin, { - upstream: upstream || `ws://127.0.0.1:${targetServer.address().port}`, + upstream: `ws://127.0.0.1:${targetServer.address().port}`, websocket: true, wsReconnect: wsReconnectOptions, wsServerOptions @@ -55,8 +63,6 @@ async function createServices ({ t, upstream, wsReconnectOptions, wsTargetOption t.after(async () => { client.close() - targetWs.close() - targetServer.close() await proxy.close() }) @@ -67,8 +73,7 @@ async function createServices ({ t, upstream, wsReconnectOptions, wsTargetOption }, proxy, client, - loggerSpy, - upstream + loggerSpy } } @@ -94,13 +99,13 @@ test('should reconnect on broken connection', async (t) => { const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) - const breakConnection = true + let breakConnection = true target.ws.on('connection', async (socket) => { socket.on('ping', async () => { // add latency to break the connection once if (breakConnection) { await wait(wsReconnectOptions.pingInterval * 2) - // breakConnection = false + breakConnection = false } socket.pong() }) @@ -157,12 +162,11 @@ test('should reconnect on regular target connection close', async (t) => { await waitForLogMessage(loggerSpy, 'proxy ws close link') }) -/* -TODO fix -test('should reconnect with retry', async (t) => { - const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, reconnectOnClose: true } +test('should reconnect retrying after a few failures', async (t) => { + const wsReconnectOptions = { pingInterval: 150, reconnectInterval: 100, reconnectDecay: 2, logs: true, maxReconnectionRetries: Infinity } - const { target, loggerSpy, upstream } = await createServices({ t, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) + const wsTargetOptions = { autoPong: false } + const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsTargetOptions }) let breakConnection = true @@ -179,14 +183,17 @@ test('should reconnect with retry', async (t) => { await waitForLogMessage(loggerSpy, 'proxy ws connection is broken') - // recreate a new target with the same upstream - + // recreate a new target + const targetPort = target.server.address().port target.ws.close() target.server.close() - await createServices({ t, upstream, wsReconnectOptions, wsTargetOptions: { autoPong: false } }) await waitForLogMessage(loggerSpy, 'proxy ws target close event') + // make reconnection fail 2 times await waitForLogMessage(loggerSpy, 'proxy ws reconnect error') + await waitForLogMessage(loggerSpy, 'proxy ws reconnect in 200 ms') + + // recreate the target + await createTargetServer(t, { autoPong: true }, targetPort) await waitForLogMessage(loggerSpy, 'proxy ws reconnected') }) -*/ From bfaf30a30d3e780f766728d504d7b46cd42bf88a Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Thu, 13 Feb 2025 14:08:04 +0100 Subject: [PATCH 15/25] feat: add onReconnect hook to wsReconnect --- README.md | 12 ++++++---- index.js | 9 +++++--- src/options.js | 11 ++++++++- test/options.js | 24 ++++++++++++++++---- test/ws-reconnect.js | 54 +++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 95 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f7bd3ae..66fca84 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,13 @@ The default implementation forwards the `cookie` header. ## `wsReconnect` -The `wsReconnect` option contains the configuration for the WebSocket reconnection feature; is an object with the following properties: +**Experimental.** (default: `disabled`) + +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). +The connection is considered broken if the target does not respond to the ping messages or no data is received from the target. + +The `wsReconnect` option contains the configuration for the WebSocket reconnection feature. +To enable the feature, set the `wsReconnect` option to an object with the following properties: - `pingInterval`: The interval between ping messages in ms (default: `30_000`). - `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 - `connectionTimeout`: The timeout for establishing the connection in ms (default: `5_000`). - `reconnectOnClose`: Whether to reconnect on close, as long as the connection from the related client to the proxy is active (default: `false`). - `logs`: Whether to log the reconnection process (default: `false`). - -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). -The connection is considered broken if the target does not respond to the ping messages or no data is received from the target. +- `onReconnect`: A hook function that is called when the connection is reconnected `async onReconnect(oldSocket, newSocket)` (default: `undefined`). ## Benchmarks diff --git a/index.js b/index.js index 5627663..8266266 100644 --- a/index.js +++ b/index.js @@ -112,7 +112,7 @@ function proxyWebSockets (source, target) { /* c8 ignore stop */ } -async function reconnect (logger, source, wsReconnectOptions, targetParams) { +async function reconnect (logger, source, wsReconnectOptions, oldTarget, targetParams) { const { url, subprotocols, optionsWs } = targetParams let attempts = 0 @@ -138,21 +138,24 @@ async function reconnect (logger, source, wsReconnectOptions, targetParams) { } wsReconnectOptions.logs && logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected') + wsReconnectOptions.onReconnect(oldTarget, target) proxyWebSocketsWithReconnection(logger, source, target, wsReconnectOptions, targetParams) } -function proxyWebSocketsWithReconnection (logger, source, target, options, targetParams, fromReconnection = false) { +function proxyWebSocketsWithReconnection (logger, source, target, options, targetParams) { function close (code, reason) { target.pingTimer && clearTimeout(source.pingTimer) target.pingTimer = undefined // reconnect target as long as the source connection is active if (source.isAlive && (target.broken || options.reconnectOnClose)) { + // clean up the target and related source listeners target.isAlive = false target.removeAllListeners() // need to specify the listeners to remove removeSourceListeners(source) - reconnect(logger, source, options, targetParams) + + reconnect(logger, source, options, target, targetParams) return } diff --git a/src/options.js b/src/options.js index d364400..8888a95 100644 --- a/src/options.js +++ b/src/options.js @@ -7,6 +7,9 @@ const DEFAULT_RECONNECT_DECAY = 1.5 const DEFAULT_CONNECTION_TIMEOUT = 5_000 const DEFAULT_RECONNECT_ON_CLOSE = false const DEFAULT_LOGS = false +const DEFAULT_ON_RECONNECT = noop + +function noop () {} function validateOptions (options) { if (!options.upstream && !options.websocket && !((options.upstream === '' || options.wsUpstream === '') && options.replyOptions && typeof options.replyOptions.getUpstream === 'function')) { @@ -50,6 +53,11 @@ function validateOptions (options) { throw new Error('wsReconnect.logs must be a boolean') } wsReconnect.logs = wsReconnect.logs ?? DEFAULT_LOGS + + if (wsReconnect.onReconnect !== undefined && typeof wsReconnect.onReconnect !== 'function') { + throw new Error('wsReconnect.onReconnect must be a function') + } + wsReconnect.onReconnect = wsReconnect.onReconnect ?? DEFAULT_ON_RECONNECT } return options @@ -63,5 +71,6 @@ module.exports = { DEFAULT_RECONNECT_DECAY, DEFAULT_CONNECTION_TIMEOUT, DEFAULT_RECONNECT_ON_CLOSE, - DEFAULT_LOGS + DEFAULT_LOGS, + DEFAULT_ON_RECONNECT } diff --git a/test/options.js b/test/options.js index f925abb..a350d14 100644 --- a/test/options.js +++ b/test/options.js @@ -3,7 +3,7 @@ const { test } = require('node:test') const assert = require('node:assert') const { validateOptions } = require('../src/options') -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') +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') test('validateOptions', (t) => { const requiredOptions = { @@ -41,11 +41,26 @@ test('validateOptions', (t) => { assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { logs: '1' } }), /wsReconnect.logs must be a boolean/) assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { logs: true } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { onReconnect: '1' } }), /wsReconnect.onReconnect must be a function/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { onReconnect: () => { } } })) + // set all values - assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { pingInterval: 1, maxReconnectionRetries: 1, reconnectInterval: 100, reconnectDecay: 1, connectionTimeout: 1, reconnectOnClose: true, logs: true } })) + assert.doesNotThrow(() => validateOptions({ + ...requiredOptions, + wsReconnect: { + pingInterval: 1, + maxReconnectionRetries: 1, + reconnectInterval: 100, + reconnectDecay: 1, + connectionTimeout: 1, + reconnectOnClose: true, + logs: true, + onReconnect: () => { } + } + })) // get default values - assert.deepEqual(validateOptions({ ...requiredOptions, wsReconnect: { } }), { + assert.deepEqual(validateOptions({ ...requiredOptions, wsReconnect: {} }), { ...requiredOptions, wsReconnect: { pingInterval: DEFAULT_PING_INTERVAL, @@ -54,7 +69,8 @@ test('validateOptions', (t) => { reconnectDecay: DEFAULT_RECONNECT_DECAY, connectionTimeout: DEFAULT_CONNECTION_TIMEOUT, reconnectOnClose: DEFAULT_RECONNECT_ON_CLOSE, - logs: DEFAULT_LOGS + logs: DEFAULT_LOGS, + onReconnect: DEFAULT_ON_RECONNECT } }) }) diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index ec9017a..196f2fb 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -73,7 +73,8 @@ async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServe }, proxy, client, - loggerSpy + loggerSpy, + logger } } @@ -144,7 +145,7 @@ test('should not reconnect after max retries', async (t) => { await waitForLogMessage(loggerSpy, 'proxy ws failed to reconnect! No more retries') }) -test('should reconnect on regular target connection close', async (t) => { +test('should not reconnect when the target connection is closed and reconnectOnClose is off', async (t) => { const wsReconnectOptions = { pingInterval: 200, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectOnClose: false, logs: true } const { target, loggerSpy } = await createServices({ t, wsReconnectOptions }) @@ -154,7 +155,7 @@ test('should reconnect on regular target connection close', async (t) => { socket.pong() }) - await wait(1_000) + await wait(500) socket.close() }) @@ -197,3 +198,50 @@ test('should reconnect retrying after a few failures', async (t) => { await createTargetServer(t, { autoPong: true }, targetPort) await waitForLogMessage(loggerSpy, 'proxy ws reconnected') }) + +test('should reconnect when the target connection is closed gracefully and reconnectOnClose is on', async (t) => { + const wsReconnectOptions = { pingInterval: 200, reconnectInterval: 100, maxReconnectionRetries: 1, reconnectOnClose: true, logs: true } + + const { target, loggerSpy } = await createServices({ t, wsReconnectOptions }) + + target.ws.on('connection', async (socket) => { + socket.on('ping', async () => { + socket.pong() + }) + + await wait(500) + socket.close() + }) + + await waitForLogMessage(loggerSpy, 'proxy ws target close event') + await waitForLogMessage(loggerSpy, 'proxy ws reconnected') +}) + +test('should call onReconnect hook function when the connection is reconnected', async (t) => { + const onReconnect = (oldSocket, newSocket) => { + logger.info('onReconnect called') + } + const wsReconnectOptions = { + pingInterval: 100, + reconnectInterval: 100, + maxReconnectionRetries: 1, + reconnectOnClose: true, + logs: true, + onReconnect + } + + const { target, loggerSpy, logger } = await createServices({ t, wsReconnectOptions }) + + target.ws.on('connection', async (socket) => { + socket.on('ping', async () => { + socket.pong() + }) + + await wait(500) + socket.close() + }) + + await waitForLogMessage(loggerSpy, 'proxy ws target close event') + await waitForLogMessage(loggerSpy, 'proxy ws reconnected') + await waitForLogMessage(loggerSpy, 'onReconnect called') +}) From 51f447dfd0a980dde733ca70dff8fbb9e013efed Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Thu, 13 Feb 2025 15:29:48 +0100 Subject: [PATCH 16/25] fix: await onReconnect --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 8266266..47b0858 100644 --- a/index.js +++ b/index.js @@ -138,7 +138,7 @@ async function reconnect (logger, source, wsReconnectOptions, oldTarget, targetP } wsReconnectOptions.logs && logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected') - wsReconnectOptions.onReconnect(oldTarget, target) + await wsReconnectOptions.onReconnect(oldTarget, target) proxyWebSocketsWithReconnection(logger, source, target, wsReconnectOptions, targetParams) } From 78597c62bd1b25a50b227f8b86ede3c5e4a468e3 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Thu, 13 Feb 2025 17:35:05 +0100 Subject: [PATCH 17/25] wip --- README.md | 4 +++- index.js | 16 ++++++++++++---- src/options.js | 16 +++++++++++++++- test/options.js | 19 ++++++++++++++++--- test/ws-reconnect.js | 35 ++++++++++++++++++++++++++++++++++- 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 66fca84..1b97957 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,9 @@ To enable the feature, set the `wsReconnect` option to an object with the follow - `connectionTimeout`: The timeout for establishing the connection in ms (default: `5_000`). - `reconnectOnClose`: Whether to reconnect on close, as long as the connection from the related client to the proxy is active (default: `false`). - `logs`: Whether to log the reconnection process (default: `false`). -- `onReconnect`: A hook function that is called when the connection is reconnected `async onReconnect(oldSocket, newSocket)` (default: `undefined`). +- `onReconnect`: A hook function that is called when the connection is reconnected `async onReconnect(source, target)` (default: `undefined`). +- `onTargetRequest`: A hook function that is called when the request is received from the client `async onTargetRequest({ data, binary })` (default: `undefined`). +- `onTargetResponse`: A hook function that is called when the response is received from the target `async onTargetResponse({ data, binary })` (default: `undefined`). ## Benchmarks diff --git a/index.js b/index.js index 47b0858..a755f83 100644 --- a/index.js +++ b/index.js @@ -138,7 +138,7 @@ async function reconnect (logger, source, wsReconnectOptions, oldTarget, targetP } wsReconnectOptions.logs && logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected') - await wsReconnectOptions.onReconnect(oldTarget, target) + await wsReconnectOptions.onReconnect(source, target) proxyWebSocketsWithReconnection(logger, source, target, wsReconnectOptions, targetParams) } @@ -174,9 +174,14 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe } /* c8 ignore start */ - function sourceOnMessage (data, binary) { + async function sourceOnMessage (data, binary) { source.isAlive = true - waitConnection(target, () => target.send(data, { binary })) + if (options.onTargetRequest) { + await options.onTargetRequest({ data, binary }) + } + waitConnection(target, () => { + target.send(data, { binary }) + }) } function sourceOnPing (data) { waitConnection(target, () => target.ping(data)) @@ -211,8 +216,11 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe // source WebSocket is already connected because it is created by ws server /* c8 ignore start */ - target.on('message', (data, binary) => { + target.on('message', async (data, binary) => { target.isAlive = true + if (options.onTargetResponse) { + await options.onTargetResponse({ data, binary }) + } source.send(data, { binary }) }) target.on('ping', data => { diff --git a/src/options.js b/src/options.js index 8888a95..e4c3780 100644 --- a/src/options.js +++ b/src/options.js @@ -8,6 +8,8 @@ const DEFAULT_CONNECTION_TIMEOUT = 5_000 const DEFAULT_RECONNECT_ON_CLOSE = false const DEFAULT_LOGS = false const DEFAULT_ON_RECONNECT = noop +const DEFAULT_ON_TARGET_REQUEST = noop +const DEFAULT_ON_TARGET_RESPONSE = noop function noop () {} @@ -58,6 +60,16 @@ function validateOptions (options) { throw new Error('wsReconnect.onReconnect must be a function') } wsReconnect.onReconnect = wsReconnect.onReconnect ?? DEFAULT_ON_RECONNECT + + if (wsReconnect.onTargetRequest !== undefined && typeof wsReconnect.onTargetRequest !== 'function') { + throw new Error('wsReconnect.onTargetRequest must be a function') + } + wsReconnect.onTargetRequest = wsReconnect.onTargetRequest ?? DEFAULT_ON_TARGET_REQUEST + + if (wsReconnect.onTargetResponse !== undefined && typeof wsReconnect.onTargetResponse !== 'function') { + throw new Error('wsReconnect.onTargetResponse must be a function') + } + wsReconnect.onTargetResponse = wsReconnect.onTargetResponse ?? DEFAULT_ON_TARGET_RESPONSE } return options @@ -72,5 +84,7 @@ module.exports = { DEFAULT_CONNECTION_TIMEOUT, DEFAULT_RECONNECT_ON_CLOSE, DEFAULT_LOGS, - DEFAULT_ON_RECONNECT + DEFAULT_ON_RECONNECT, + DEFAULT_ON_TARGET_REQUEST, + DEFAULT_ON_TARGET_RESPONSE } diff --git a/test/options.js b/test/options.js index a350d14..b413c71 100644 --- a/test/options.js +++ b/test/options.js @@ -3,7 +3,10 @@ const { test } = require('node:test') const assert = require('node:assert') const { validateOptions } = require('../src/options') -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') +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, DEFAULT_ON_TARGET_REQUEST, DEFAULT_ON_TARGET_RESPONSE +} = require('../src/options') test('validateOptions', (t) => { const requiredOptions = { @@ -44,6 +47,12 @@ test('validateOptions', (t) => { assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { onReconnect: '1' } }), /wsReconnect.onReconnect must be a function/) assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { onReconnect: () => { } } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { onTargetRequest: '1' } }), /wsReconnect.onTargetRequest must be a function/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { onTargetRequest: () => { } } })) + + assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { onTargetResponse: '1' } }), /wsReconnect.onTargetResponse must be a function/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { onTargetResponse: () => { } } })) + // set all values assert.doesNotThrow(() => validateOptions({ ...requiredOptions, @@ -55,7 +64,9 @@ test('validateOptions', (t) => { connectionTimeout: 1, reconnectOnClose: true, logs: true, - onReconnect: () => { } + onReconnect: () => { }, + onTargetRequest: () => { }, + onTargetResponse: () => { } } })) @@ -70,7 +81,9 @@ test('validateOptions', (t) => { connectionTimeout: DEFAULT_CONNECTION_TIMEOUT, reconnectOnClose: DEFAULT_RECONNECT_ON_CLOSE, logs: DEFAULT_LOGS, - onReconnect: DEFAULT_ON_RECONNECT + onReconnect: DEFAULT_ON_RECONNECT, + onTargetRequest: DEFAULT_ON_TARGET_REQUEST, + onTargetResponse: DEFAULT_ON_TARGET_RESPONSE } }) }) diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 196f2fb..e595a60 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -218,7 +218,7 @@ test('should reconnect when the target connection is closed gracefully and recon }) test('should call onReconnect hook function when the connection is reconnected', async (t) => { - const onReconnect = (oldSocket, newSocket) => { + const onReconnect = (source, target) => { logger.info('onReconnect called') } const wsReconnectOptions = { @@ -245,3 +245,36 @@ test('should call onReconnect hook function when the connection is reconnected', await waitForLogMessage(loggerSpy, 'proxy ws reconnected') await waitForLogMessage(loggerSpy, 'onReconnect called') }) + +test('should call onTargetRequest hook function when the request is received from the client', async (t) => { + const message = 'query () { ... }' + const response = 'data ...' + const onTargetRequest = ({ data, binary }) => { + assert.strictEqual(data, message) + assert.strictEqual(binary, false) + logger.info('onTargetRequest called') + } + const wsReconnectOptions = { + pingInterval: 100, + reconnectInterval: 100, + maxReconnectionRetries: 1, + reconnectOnClose: true, + logs: true, + onTargetRequest + } + + const { target, loggerSpy, logger } = await createServices({ t, wsReconnectOptions }) + + target.ws.on('connection', async (socket) => { + socket.on('message', async (data, binary) => { + socket.send(response, { binary }) + }) + }) + + await waitForLogMessage(loggerSpy, 'proxy ws target close event') + await waitForLogMessage(loggerSpy, 'proxy ws reconnected') + await waitForLogMessage(loggerSpy, 'onTargetRequest called') +}) + + +// TODO onTargetResponse \ No newline at end of file From 08d73948fa3e6ce762dce0065f38f8708eb21134 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Fri, 14 Feb 2025 10:37:07 +0100 Subject: [PATCH 18/25] feat: introduce hooks on message --- README.md | 5 +- index.js | 78 +++++++++++++------- src/options.js | 28 +++++--- test/helper/helper.js | 83 ++++++++++++++++++++++ test/options.js | 16 +++-- test/websocket.js | 58 +++++++++++++++ test/ws-reconnect.js | 161 ++++++++++++++++++++---------------------- 7 files changed, 302 insertions(+), 127 deletions(-) create mode 100644 test/helper/helper.js diff --git a/README.md b/README.md index 1b97957..4b66001 100644 --- a/README.md +++ b/README.md @@ -244,9 +244,12 @@ To enable the feature, set the `wsReconnect` option to an object with the follow - `connectionTimeout`: The timeout for establishing the connection in ms (default: `5_000`). - `reconnectOnClose`: Whether to reconnect on close, as long as the connection from the related client to the proxy is active (default: `false`). - `logs`: Whether to log the reconnection process (default: `false`). -- `onReconnect`: A hook function that is called when the connection is reconnected `async onReconnect(source, target)` (default: `undefined`). + +## wsHooks + - `onTargetRequest`: A hook function that is called when the request is received from the client `async onTargetRequest({ data, binary })` (default: `undefined`). - `onTargetResponse`: A hook function that is called when the response is received from the target `async onTargetResponse({ data, binary })` (default: `undefined`). +- `onReconnect`: A hook function that is called when the connection is reconnected `async onReconnect(source, target)` (default: `undefined`). ## Benchmarks diff --git a/index.js b/index.js index a755f83..71d159d 100644 --- a/index.js +++ b/index.js @@ -82,13 +82,22 @@ function isExternalUrl (url) { function noop () { } -function proxyWebSockets (source, target) { +function proxyWebSockets (logger, source, target, hooks) { function close (code, reason) { closeWebSocket(source, code, reason) closeWebSocket(target, code, reason) } - source.on('message', (data, binary) => waitConnection(target, () => target.send(data, { binary }))) + source.on('message', async (data, binary) => { + if (hooks.onTargetRequest) { + try { + await hooks.onTargetRequest({ data, binary }) + } catch (err) { + logger.error({ err }, 'proxy ws error from onTargetRequest hook') + } + } + waitConnection(target, () => target.send(data, { binary })) + }) /* c8 ignore start */ source.on('ping', data => waitConnection(target, () => target.ping(data))) source.on('pong', data => waitConnection(target, () => target.pong(data))) @@ -100,7 +109,16 @@ function proxyWebSockets (source, target) { /* c8 ignore stop */ // source WebSocket is already connected because it is created by ws server - target.on('message', (data, binary) => source.send(data, { binary })) + target.on('message', async (data, binary) => { + if (hooks.onTargetResponse) { + try { + await hooks.onTargetResponse({ data, binary }) + } catch (err) { + logger.error({ err }, 'proxy ws error from onTargetResponse hook') + } + } + source.send(data, { binary }) + }) /* c8 ignore start */ target.on('ping', data => source.ping(data)) /* c8 ignore stop */ @@ -112,37 +130,41 @@ function proxyWebSockets (source, target) { /* c8 ignore stop */ } -async function reconnect (logger, source, wsReconnectOptions, oldTarget, targetParams) { +async function reconnect (logger, source, reconnectOptions, hooks, targetParams) { const { url, subprotocols, optionsWs } = targetParams let attempts = 0 let target do { - const reconnectWait = wsReconnectOptions.reconnectInterval * (wsReconnectOptions.reconnectDecay * attempts || 1) - wsReconnectOptions.logs && logger.warn({ target: targetParams.url }, `proxy ws reconnect in ${reconnectWait} ms`) + const reconnectWait = reconnectOptions.reconnectInterval * (reconnectOptions.reconnectDecay * attempts || 1) + reconnectOptions.logs && logger.warn({ target: targetParams.url }, `proxy ws reconnect in ${reconnectWait} ms`) await wait(reconnectWait) try { target = new WebSocket(url, subprotocols, optionsWs) - await waitForConnection(target, wsReconnectOptions.connectionTimeout) + await waitForConnection(target, reconnectOptions.connectionTimeout) } catch (err) { - wsReconnectOptions.logs && logger.error({ target: targetParams.url, err, attempts }, 'proxy ws reconnect error') + reconnectOptions.logs && logger.error({ target: targetParams.url, err, attempts }, 'proxy ws reconnect error') attempts++ target = undefined } - } while (!target && attempts < wsReconnectOptions.maxReconnectionRetries) + } while (!target && attempts < reconnectOptions.maxReconnectionRetries) if (!target) { logger.error({ target: targetParams.url, attempts }, 'proxy ws failed to reconnect! No more retries') return } - wsReconnectOptions.logs && logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected') - await wsReconnectOptions.onReconnect(source, target) - proxyWebSocketsWithReconnection(logger, source, target, wsReconnectOptions, targetParams) + reconnectOptions.logs && logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected') + try { + await hooks.onReconnect(source, target) + } catch (err) { + reconnectOptions.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onReconnect hook') + } + proxyWebSocketsWithReconnection(logger, source, target, reconnectOptions, hooks, targetParams) } -function proxyWebSocketsWithReconnection (logger, source, target, options, targetParams) { +function proxyWebSocketsWithReconnection (logger, source, target, options, hooks, targetParams) { function close (code, reason) { target.pingTimer && clearTimeout(source.pingTimer) target.pingTimer = undefined @@ -155,7 +177,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe // need to specify the listeners to remove removeSourceListeners(source) - reconnect(logger, source, options, target, targetParams) + reconnect(logger, source, options, hooks, targetParams) return } @@ -176,12 +198,14 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe /* c8 ignore start */ async function sourceOnMessage (data, binary) { source.isAlive = true - if (options.onTargetRequest) { - await options.onTargetRequest({ data, binary }) + if (hooks.onTargetRequest) { + try { + await hooks.onTargetRequest({ data, binary }) + } catch (err) { + logger.error({ target: targetParams.url, err }, 'proxy ws error from onTargetRequest hook') + } } - waitConnection(target, () => { - target.send(data, { binary }) - }) + waitConnection(target, () => target.send(data, { binary })) } function sourceOnPing (data) { waitConnection(target, () => target.ping(data)) @@ -218,8 +242,12 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, targe /* c8 ignore start */ target.on('message', async (data, binary) => { target.isAlive = true - if (options.onTargetResponse) { - await options.onTargetResponse({ data, binary }) + if (hooks.onTargetResponse) { + try { + await hooks.onTargetResponse({ data, binary }) + } catch (err) { + logger.error({ target: targetParams.url, err }, 'proxy ws error from onTargetResponse hook') + } } source.send(data, { binary }) }) @@ -276,7 +304,7 @@ function handleUpgrade (fastify, rawRequest, socket, head) { } class WebSocketProxy { - constructor (fastify, { wsReconnect, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { + constructor (fastify, { wsReconnect, wsHooks, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { this.logger = fastify.log this.wsClientOptions = { rewriteRequestHeaders: defaultWsHeadersRewrite, @@ -287,7 +315,7 @@ class WebSocketProxy { this.wsUpstream = wsUpstream ? convertUrlToWebSocket(wsUpstream) : '' this.getUpstream = getUpstream this.wsReconnect = wsReconnect - + this.wsHooks = wsHooks const wss = new WebSocket.Server({ noServer: true, ...wsServerOptions @@ -379,9 +407,9 @@ class WebSocketProxy { if (this.wsReconnect) { const targetParams = { url, subprotocols, optionsWs } - proxyWebSocketsWithReconnection(this.logger, source, target, this.wsReconnect, targetParams) + proxyWebSocketsWithReconnection(this.logger, source, target, this.wsReconnect, this.wsHooks, targetParams) } else { - proxyWebSockets(source, target) + proxyWebSockets(this.logger, source, target, this.wsHooks) } } } diff --git a/src/options.js b/src/options.js index e4c3780..9855003 100644 --- a/src/options.js +++ b/src/options.js @@ -55,21 +55,31 @@ function validateOptions (options) { throw new Error('wsReconnect.logs must be a boolean') } wsReconnect.logs = wsReconnect.logs ?? DEFAULT_LOGS + } + + if (options.wsHooks) { + const wsHooks = options.wsHooks - if (wsReconnect.onReconnect !== undefined && typeof wsReconnect.onReconnect !== 'function') { - throw new Error('wsReconnect.onReconnect must be a function') + if (wsHooks.onReconnect !== undefined && typeof wsHooks.onReconnect !== 'function') { + throw new Error('wsHooks.onReconnect must be a function') } - wsReconnect.onReconnect = wsReconnect.onReconnect ?? DEFAULT_ON_RECONNECT + wsHooks.onReconnect = wsHooks.onReconnect ?? DEFAULT_ON_RECONNECT - if (wsReconnect.onTargetRequest !== undefined && typeof wsReconnect.onTargetRequest !== 'function') { - throw new Error('wsReconnect.onTargetRequest must be a function') + if (wsHooks.onTargetRequest !== undefined && typeof wsHooks.onTargetRequest !== 'function') { + throw new Error('wsHooks.onTargetRequest must be a function') } - wsReconnect.onTargetRequest = wsReconnect.onTargetRequest ?? DEFAULT_ON_TARGET_REQUEST + wsHooks.onTargetRequest = wsHooks.onTargetRequest ?? DEFAULT_ON_TARGET_REQUEST - if (wsReconnect.onTargetResponse !== undefined && typeof wsReconnect.onTargetResponse !== 'function') { - throw new Error('wsReconnect.onTargetResponse must be a function') + if (wsHooks.onTargetResponse !== undefined && typeof wsHooks.onTargetResponse !== 'function') { + throw new Error('wsHooks.onTargetResponse must be a function') + } + wsHooks.onTargetResponse = wsHooks.onTargetResponse ?? DEFAULT_ON_TARGET_RESPONSE + } else { + options.wsHooks = { + onReconnect: DEFAULT_ON_RECONNECT, + onTargetRequest: DEFAULT_ON_TARGET_REQUEST, + onTargetResponse: DEFAULT_ON_TARGET_RESPONSE } - wsReconnect.onTargetResponse = wsReconnect.onTargetResponse ?? DEFAULT_ON_TARGET_RESPONSE } return options diff --git a/test/helper/helper.js b/test/helper/helper.js new file mode 100644 index 0000000..c216fb1 --- /dev/null +++ b/test/helper/helper.js @@ -0,0 +1,83 @@ +'use strict' + +const { createServer } = require('node:http') +const { promisify } = require('node:util') +const { once } = require('node:events') +const Fastify = require('fastify') +const WebSocket = require('ws') +const pinoTest = require('pino-test') +const pino = require('pino') +const proxyPlugin = require('../../') + +function waitForLogMessage (loggerSpy, message, max = 100) { + return new Promise((resolve, reject) => { + let count = 0 + const fn = (received) => { + if (received.msg === message) { + loggerSpy.off('data', fn) + resolve() + } + count++ + if (count > max) { + loggerSpy.off('data', fn) + reject(new Error(`Max message count reached on waitForLogMessage: ${message}`)) + } + } + loggerSpy.on('data', fn) + }) +} + +async function createTargetServer (t, wsTargetOptions, port = 0) { + const targetServer = createServer() + const targetWs = new WebSocket.Server({ server: targetServer, ...wsTargetOptions }) + await promisify(targetServer.listen.bind(targetServer))({ port, host: '127.0.0.1' }) + + t.after(() => { + targetWs.close() + targetServer.close() + }) + + return { targetServer, targetWs } +} + +async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServerOptions, wsHooks, targetPort = 0 }) { + const { targetServer, targetWs } = await createTargetServer(t, wsTargetOptions, targetPort) + + const loggerSpy = pinoTest.sink() + const logger = pino(loggerSpy) + const proxy = Fastify({ loggerInstance: logger }) + proxy.register(proxyPlugin, { + upstream: `ws://127.0.0.1:${targetServer.address().port}`, + websocket: true, + wsReconnect: wsReconnectOptions, + wsServerOptions, + wsHooks + }) + + await proxy.listen({ port: 0, host: '127.0.0.1' }) + + const client = new WebSocket(`ws://127.0.0.1:${proxy.server.address().port}`) + await once(client, 'open') + + t.after(async () => { + client.close() + await proxy.close() + }) + + return { + target: { + ws: targetWs, + server: targetServer + }, + proxy, + client, + loggerSpy, + logger + } +} + +module.exports = { + waitForLogMessage, + createTargetServer, + createServices +} diff --git a/test/options.js b/test/options.js index b413c71..b21537f 100644 --- a/test/options.js +++ b/test/options.js @@ -44,14 +44,14 @@ test('validateOptions', (t) => { assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { logs: '1' } }), /wsReconnect.logs must be a boolean/) assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { logs: true } })) - assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { onReconnect: '1' } }), /wsReconnect.onReconnect must be a function/) - assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { onReconnect: () => { } } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsHooks: { onReconnect: '1' } }), /wsHooks.onReconnect must be a function/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsHooks: { onReconnect: () => { } } })) - assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { onTargetRequest: '1' } }), /wsReconnect.onTargetRequest must be a function/) - assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { onTargetRequest: () => { } } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsHooks: { onTargetRequest: '1' } }), /wsHooks.onTargetRequest must be a function/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsHooks: { onTargetRequest: () => { } } })) - assert.throws(() => validateOptions({ ...requiredOptions, wsReconnect: { onTargetResponse: '1' } }), /wsReconnect.onTargetResponse must be a function/) - assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsReconnect: { onTargetResponse: () => { } } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsHooks: { onTargetResponse: '1' } }), /wsHooks.onTargetResponse must be a function/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsHooks: { onTargetResponse: () => { } } })) // set all values assert.doesNotThrow(() => validateOptions({ @@ -64,6 +64,8 @@ test('validateOptions', (t) => { connectionTimeout: 1, reconnectOnClose: true, logs: true, + }, + wsHooks: { onReconnect: () => { }, onTargetRequest: () => { }, onTargetResponse: () => { } @@ -81,6 +83,8 @@ test('validateOptions', (t) => { connectionTimeout: DEFAULT_CONNECTION_TIMEOUT, reconnectOnClose: DEFAULT_RECONNECT_ON_CLOSE, logs: DEFAULT_LOGS, + }, + wsHooks: { onReconnect: DEFAULT_ON_RECONNECT, onTargetRequest: DEFAULT_ON_TARGET_REQUEST, onTargetResponse: DEFAULT_ON_TARGET_RESPONSE diff --git a/test/websocket.js b/test/websocket.js index ba7e125..1e31b10 100644 --- a/test/websocket.js +++ b/test/websocket.js @@ -1,12 +1,14 @@ 'use strict' const { test } = require('node:test') +const assert = require('node:assert') const Fastify = require('fastify') const proxy = require('../') const WebSocket = require('ws') const { createServer } = require('node:http') const { promisify } = require('node:util') const { once } = require('node:events') +const { waitForLogMessage, createServices } = require('./helper/helper') const cookieValue = 'foo=bar' const subprotocolValue = 'foo-subprotocol' @@ -710,3 +712,59 @@ test('multiple websocket upstreams with distinct server options', async (t) => { server.close() ]) }) + +test('should call onTargetRequest and onTargetResponse hooks', async (t) => { + const request = 'query () { ... }' + const response = 'data ...' + const onTargetRequest = ({ data, binary }) => { + assert.strictEqual(data.toString(), request) + assert.strictEqual(binary, false) + logger.info('onTargetRequest called') + } + const onTargetResponse = ({ data, binary }) => { + assert.strictEqual(data.toString(), response) + assert.strictEqual(binary, false) + logger.info('onTargetResponse called') + } + + const { target, loggerSpy, logger, client } = await createServices({ t, wsHooks: { onTargetRequest, onTargetResponse } }) + + target.ws.on('connection', async (socket) => { + socket.on('message', async (data, binary) => { + socket.send(response, { binary }) + }) + }) + + client.send(request) + + await waitForLogMessage(loggerSpy, 'onTargetRequest called') + await waitForLogMessage(loggerSpy, 'onTargetResponse called') +}) + +test('should handle throwing an error in onTargetRequest and onTargetResponse hooks', async (t) => { + const request = 'query () { ... }' + const response = 'data ...' + const onTargetRequest = ({ data, binary }) => { + assert.strictEqual(data.toString(), request) + assert.strictEqual(binary, false) + throw new Error('onTargetRequest error') + } + const onTargetResponse = ({ data, binary }) => { + assert.strictEqual(data.toString(), response) + assert.strictEqual(binary, false) + throw new Error('onTargetResponse error') + } + + const { target, loggerSpy, client } = await createServices({ t, wsHooks: { onTargetRequest, onTargetResponse } }) + + target.ws.on('connection', async (socket) => { + socket.on('message', async (data, binary) => { + socket.send(response, { binary }) + }) + }) + + client.send(request) + + await waitForLogMessage(loggerSpy, 'proxy ws error from onTargetRequest hook') + await waitForLogMessage(loggerSpy, 'proxy ws error from onTargetResponse hook') +}) diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index e595a60..05351c6 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -2,81 +2,8 @@ const { test } = require('node:test') const assert = require('node:assert') -const { createServer } = require('node:http') -const { promisify } = require('node:util') -const { once } = require('node:events') const { setTimeout: wait } = require('node:timers/promises') -const Fastify = require('fastify') -const WebSocket = require('ws') -const pinoTest = require('pino-test') -const pino = require('pino') -const proxyPlugin = require('../') - -function waitForLogMessage (loggerSpy, message, max = 100) { - return new Promise((resolve, reject) => { - let count = 0 - const fn = (received) => { - if (received.msg === message) { - loggerSpy.off('data', fn) - resolve() - } - count++ - if (count > max) { - loggerSpy.off('data', fn) - reject(new Error(`Max message count reached on waitForLogMessage: ${message}`)) - } - } - loggerSpy.on('data', fn) - }) -} - -async function createTargetServer (t, wsTargetOptions, port = 0) { - const targetServer = createServer() - const targetWs = new WebSocket.Server({ server: targetServer, ...wsTargetOptions }) - await promisify(targetServer.listen.bind(targetServer))({ port, host: '127.0.0.1' }) - - t.after(() => { - targetWs.close() - targetServer.close() - }) - - return { targetServer, targetWs } -} - -async function createServices ({ t, wsReconnectOptions, wsTargetOptions, wsServerOptions, targetPort = 0 }) { - const { targetServer, targetWs } = await createTargetServer(t, wsTargetOptions, targetPort) - - const loggerSpy = pinoTest.sink() - const logger = pino(loggerSpy) - const proxy = Fastify({ loggerInstance: logger }) - proxy.register(proxyPlugin, { - upstream: `ws://127.0.0.1:${targetServer.address().port}`, - websocket: true, - wsReconnect: wsReconnectOptions, - wsServerOptions - }) - - await proxy.listen({ port: 0, host: '127.0.0.1' }) - - const client = new WebSocket(`ws://127.0.0.1:${proxy.server.address().port}`) - await once(client, 'open') - - t.after(async () => { - client.close() - await proxy.close() - }) - - return { - target: { - ws: targetWs, - server: targetServer - }, - proxy, - client, - loggerSpy, - logger - } -} +const { waitForLogMessage, createTargetServer, createServices } = require('./helper/helper') test('should use ping/pong to verify connection is alive - from source (server on proxy) to target', async (t) => { const wsReconnectOptions = { pingInterval: 100, reconnectInterval: 100, maxReconnectionRetries: 1 } @@ -217,7 +144,7 @@ test('should reconnect when the target connection is closed gracefully and recon await waitForLogMessage(loggerSpy, 'proxy ws reconnected') }) -test('should call onReconnect hook function when the connection is reconnected', async (t) => { +test('should call onReconnect hook when the connection is reconnected', async (t) => { const onReconnect = (source, target) => { logger.info('onReconnect called') } @@ -227,10 +154,9 @@ test('should call onReconnect hook function when the connection is reconnected', maxReconnectionRetries: 1, reconnectOnClose: true, logs: true, - onReconnect } - const { target, loggerSpy, logger } = await createServices({ t, wsReconnectOptions }) + const { target, loggerSpy, logger } = await createServices({ t, wsReconnectOptions, wsHooks: { onReconnect } }) target.ws.on('connection', async (socket) => { socket.on('ping', async () => { @@ -246,24 +172,55 @@ test('should call onReconnect hook function when the connection is reconnected', await waitForLogMessage(loggerSpy, 'onReconnect called') }) -test('should call onTargetRequest hook function when the request is received from the client', async (t) => { - const message = 'query () { ... }' +test('should handle throwing an error in onReconnect hook', async (t) => { + const onReconnect = (source, target) => { + throw new Error('onReconnect error') + } + const wsReconnectOptions = { + pingInterval: 100, + reconnectInterval: 100, + maxReconnectionRetries: 1, + reconnectOnClose: true, + logs: true, + } + + const { target, loggerSpy } = await createServices({ t, wsReconnectOptions, wsHooks: { onReconnect } }) + + target.ws.on('connection', async (socket) => { + socket.on('ping', async () => { + socket.pong() + }) + + await wait(500) + socket.close() + }) + + await waitForLogMessage(loggerSpy, 'proxy ws target close event') + await waitForLogMessage(loggerSpy, 'proxy ws reconnected') + await waitForLogMessage(loggerSpy, 'proxy ws error from onReconnect hook') +}) + +test('should call onTargetRequest and onTargetResponse hooks, with reconnection', async (t) => { + const request = 'query () { ... }' const response = 'data ...' const onTargetRequest = ({ data, binary }) => { - assert.strictEqual(data, message) + assert.strictEqual(data.toString(), request) assert.strictEqual(binary, false) logger.info('onTargetRequest called') } + const onTargetResponse = ({ data, binary }) => { + assert.strictEqual(data.toString(), response) + assert.strictEqual(binary, false) + logger.info('onTargetResponse called') + } const wsReconnectOptions = { pingInterval: 100, reconnectInterval: 100, maxReconnectionRetries: 1, - reconnectOnClose: true, logs: true, - onTargetRequest } - const { target, loggerSpy, logger } = await createServices({ t, wsReconnectOptions }) + const { target, loggerSpy, logger, client } = await createServices({ t, wsReconnectOptions, wsHooks: { onTargetRequest, onTargetResponse } }) target.ws.on('connection', async (socket) => { socket.on('message', async (data, binary) => { @@ -271,10 +228,42 @@ test('should call onTargetRequest hook function when the request is received fro }) }) - await waitForLogMessage(loggerSpy, 'proxy ws target close event') - await waitForLogMessage(loggerSpy, 'proxy ws reconnected') + client.send(request) + await waitForLogMessage(loggerSpy, 'onTargetRequest called') + await waitForLogMessage(loggerSpy, 'onTargetResponse called') }) +test('should handle throwing an error in onTargetRequest and onTargetResponse hooks, with reconnection', async (t) => { + const request = 'query () { ... }' + const response = 'data ...' + const onTargetRequest = ({ data, binary }) => { + assert.strictEqual(data.toString(), request) + assert.strictEqual(binary, false) + throw new Error('onTargetRequest error') + } + const onTargetResponse = ({ data, binary }) => { + assert.strictEqual(data.toString(), response) + assert.strictEqual(binary, false) + throw new Error('onTargetResponse error') + } + const wsReconnectOptions = { + pingInterval: 100, + reconnectInterval: 100, + maxReconnectionRetries: 1, + logs: true, + } + + const { target, loggerSpy, client } = await createServices({ t, wsReconnectOptions, wsHooks: { onTargetRequest, onTargetResponse } }) -// TODO onTargetResponse \ No newline at end of file + target.ws.on('connection', async (socket) => { + socket.on('message', async (data, binary) => { + socket.send(response, { binary }) + }) + }) + + client.send(request) + + await waitForLogMessage(loggerSpy, 'proxy ws error from onTargetRequest hook') + await waitForLogMessage(loggerSpy, 'proxy ws error from onTargetResponse hook') +}) From 08bcc070f74dbdbbbc9970dff9e1e694cac4a079 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Fri, 14 Feb 2025 10:44:59 +0100 Subject: [PATCH 19/25] chore: default hooks --- index.js | 10 ++++++---- src/options.js | 17 +++-------------- test/options.js | 9 ++++----- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/index.js b/index.js index 71d159d..4b0fbe0 100644 --- a/index.js +++ b/index.js @@ -156,10 +156,12 @@ async function reconnect (logger, source, reconnectOptions, hooks, targetParams) } reconnectOptions.logs && logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected') - try { - await hooks.onReconnect(source, target) - } catch (err) { - reconnectOptions.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onReconnect hook') + if (hooks.onReconnect) { + try { + await hooks.onReconnect(source, target) + } catch (err) { + reconnectOptions.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onReconnect hook') + } } proxyWebSocketsWithReconnection(logger, source, target, reconnectOptions, hooks, targetParams) } diff --git a/src/options.js b/src/options.js index 9855003..cd332da 100644 --- a/src/options.js +++ b/src/options.js @@ -7,11 +7,6 @@ const DEFAULT_RECONNECT_DECAY = 1.5 const DEFAULT_CONNECTION_TIMEOUT = 5_000 const DEFAULT_RECONNECT_ON_CLOSE = false const DEFAULT_LOGS = false -const DEFAULT_ON_RECONNECT = noop -const DEFAULT_ON_TARGET_REQUEST = noop -const DEFAULT_ON_TARGET_RESPONSE = noop - -function noop () {} function validateOptions (options) { if (!options.upstream && !options.websocket && !((options.upstream === '' || options.wsUpstream === '') && options.replyOptions && typeof options.replyOptions.getUpstream === 'function')) { @@ -63,22 +58,19 @@ function validateOptions (options) { if (wsHooks.onReconnect !== undefined && typeof wsHooks.onReconnect !== 'function') { throw new Error('wsHooks.onReconnect must be a function') } - wsHooks.onReconnect = wsHooks.onReconnect ?? DEFAULT_ON_RECONNECT if (wsHooks.onTargetRequest !== undefined && typeof wsHooks.onTargetRequest !== 'function') { throw new Error('wsHooks.onTargetRequest must be a function') } - wsHooks.onTargetRequest = wsHooks.onTargetRequest ?? DEFAULT_ON_TARGET_REQUEST if (wsHooks.onTargetResponse !== undefined && typeof wsHooks.onTargetResponse !== 'function') { throw new Error('wsHooks.onTargetResponse must be a function') } - wsHooks.onTargetResponse = wsHooks.onTargetResponse ?? DEFAULT_ON_TARGET_RESPONSE } else { options.wsHooks = { - onReconnect: DEFAULT_ON_RECONNECT, - onTargetRequest: DEFAULT_ON_TARGET_REQUEST, - onTargetResponse: DEFAULT_ON_TARGET_RESPONSE + onReconnect: undefined, + onTargetRequest: undefined, + onTargetResponse: undefined } } @@ -94,7 +86,4 @@ module.exports = { DEFAULT_CONNECTION_TIMEOUT, DEFAULT_RECONNECT_ON_CLOSE, DEFAULT_LOGS, - DEFAULT_ON_RECONNECT, - DEFAULT_ON_TARGET_REQUEST, - DEFAULT_ON_TARGET_RESPONSE } diff --git a/test/options.js b/test/options.js index b21537f..6c55c08 100644 --- a/test/options.js +++ b/test/options.js @@ -4,8 +4,7 @@ const { test } = require('node:test') const assert = require('node:assert') const { validateOptions } = require('../src/options') 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, DEFAULT_ON_TARGET_REQUEST, DEFAULT_ON_TARGET_RESPONSE + 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') test('validateOptions', (t) => { @@ -85,9 +84,9 @@ test('validateOptions', (t) => { logs: DEFAULT_LOGS, }, wsHooks: { - onReconnect: DEFAULT_ON_RECONNECT, - onTargetRequest: DEFAULT_ON_TARGET_REQUEST, - onTargetResponse: DEFAULT_ON_TARGET_RESPONSE + onReconnect: undefined, + onTargetRequest: undefined, + onTargetResponse: undefined } }) }) From 83047a92e308086804592904c20484376ba0d176 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Mon, 17 Feb 2025 17:34:20 +0100 Subject: [PATCH 20/25] wip --- README.md | 8 ++- examples/ws-reconnection.js | 1 + index.js | 140 ++++++++++++++++++++++-------------- src/options.js | 12 ++-- test/options.js | 16 ++--- test/websocket.js | 34 ++++----- test/ws-reconnect.js | 34 ++++----- 7 files changed, 142 insertions(+), 103 deletions(-) create mode 100644 examples/ws-reconnection.js diff --git a/README.md b/README.md index 4b66001..c0a0a1f 100644 --- a/README.md +++ b/README.md @@ -247,9 +247,11 @@ To enable the feature, set the `wsReconnect` option to an object with the follow ## wsHooks -- `onTargetRequest`: A hook function that is called when the request is received from the client `async onTargetRequest({ data, binary })` (default: `undefined`). -- `onTargetResponse`: A hook function that is called when the response is received from the target `async onTargetResponse({ data, binary })` (default: `undefined`). -- `onReconnect`: A hook function that is called when the connection is reconnected `async onReconnect(source, target)` (default: `undefined`). +- `onConnect`: A hook function that is called when the connection is established `onConnect(source, target)` (default: `undefined`). +- `onDisconnect`: A hook function that is called when the connection is closed `onDisconnect(source)` (default: `undefined`). +- `onReconnect`: A hook function that is called when the connection is reconnected `onReconnect(source, target)` (default: `undefined`). +- `onIncomingMessage`: A hook function that is called when the request is received from the client `onIncomingMessage({ data, binary })` (default: `undefined`). +- `onOutgoingMessage`: A hook function that is called when the response is received from the target `onOutgoingMessage({ data, binary })` (default: `undefined`). ## Benchmarks diff --git a/examples/ws-reconnection.js b/examples/ws-reconnection.js new file mode 100644 index 0000000..0ffdd02 --- /dev/null +++ b/examples/ws-reconnection.js @@ -0,0 +1 @@ +// TODO \ No newline at end of file diff --git a/index.js b/index.js index 4b0fbe0..14b2e8d 100644 --- a/index.js +++ b/index.js @@ -14,7 +14,7 @@ const kWs = Symbol('ws') const kWsHead = Symbol('wsHead') const kWsUpgradeListener = Symbol('wsUpgradeListener') -function liftErrorCode (code) { +function liftErrorCode(code) { /* c8 ignore start */ if (typeof code !== 'number') { // Sometimes "close" event emits with a non-numeric value @@ -28,14 +28,14 @@ function liftErrorCode (code) { /* c8 ignore stop */ } -function closeWebSocket (socket, code, reason) { +function closeWebSocket(socket, code, reason) { socket.isAlive = false if (socket.readyState === WebSocket.OPEN) { socket.close(liftErrorCode(code), reason) } } -function waitConnection (socket, write) { +function waitConnection(socket, write) { if (socket.readyState === WebSocket.CONNECTING) { socket.once('open', write) } else { @@ -43,7 +43,7 @@ function waitConnection (socket, write) { } } -function waitForConnection (target, timeout) { +function waitForConnection(target, timeout) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { /* c8 ignore start */ @@ -76,24 +76,24 @@ function waitForConnection (target, timeout) { }) } -function isExternalUrl (url) { +function isExternalUrl(url) { return urlPattern.test(url) } -function noop () { } +function noop() { } -function proxyWebSockets (logger, source, target, hooks) { - function close (code, reason) { +function proxyWebSockets(logger, source, target, hooks) { + function close(code, reason) { closeWebSocket(source, code, reason) closeWebSocket(target, code, reason) } - source.on('message', async (data, binary) => { - if (hooks.onTargetRequest) { + source.on('message', (data, binary) => { + if (hooks.onIncomingMessage) { try { - await hooks.onTargetRequest({ data, binary }) + hooks.onIncomingMessage({ data, binary }) } catch (err) { - logger.error({ err }, 'proxy ws error from onTargetRequest hook') + logger.error({ err }, 'proxy ws error from onIncomingMessage hook') } } waitConnection(target, () => target.send(data, { binary })) @@ -109,12 +109,12 @@ function proxyWebSockets (logger, source, target, hooks) { /* c8 ignore stop */ // source WebSocket is already connected because it is created by ws server - target.on('message', async (data, binary) => { - if (hooks.onTargetResponse) { + target.on('message', (data, binary) => { + if (hooks.onOutgoingMessage) { try { - await hooks.onTargetResponse({ data, binary }) + hooks.onOutgoingMessage({ data, binary }) } catch (err) { - logger.error({ err }, 'proxy ws error from onTargetResponse hook') + logger.error({ err }, 'proxy ws error from onOutgoingMessage hook') } } source.send(data, { binary }) @@ -128,9 +128,19 @@ function proxyWebSockets (logger, source, target, hooks) { target.on('error', error => close(1011, error.message)) target.on('unexpected-response', () => close(1011, 'unexpected response')) /* c8 ignore stop */ + + if (hooks.onConnect) { + waitConnection(target, () => { + try { + hooks.onConnect(source, target) + } catch (err) { + logger.error({ target: targetParams.url, err }, 'proxy ws error from onConnect hook') + } + }) + } } -async function reconnect (logger, source, reconnectOptions, hooks, targetParams) { +async function reconnect(logger, source, reconnectOptions, hooks, targetParams) { const { url, subprotocols, optionsWs } = targetParams let attempts = 0 @@ -156,21 +166,22 @@ async function reconnect (logger, source, reconnectOptions, hooks, targetParams) } reconnectOptions.logs && logger.info({ target: targetParams.url, attempts }, 'proxy ws reconnected') - if (hooks.onReconnect) { - try { - await hooks.onReconnect(source, target) - } catch (err) { - reconnectOptions.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onReconnect hook') - } - } - proxyWebSocketsWithReconnection(logger, source, target, reconnectOptions, hooks, targetParams) + proxyWebSocketsWithReconnection(logger, source, target, reconnectOptions, hooks, targetParams, true) } -function proxyWebSocketsWithReconnection (logger, source, target, options, hooks, targetParams) { - function close (code, reason) { - target.pingTimer && clearTimeout(source.pingTimer) +function proxyWebSocketsWithReconnection(logger, source, target, options, hooks, targetParams, isReconnecting = false) { + function close(code, reason) { + target.pingTimer && clearInterval(target.pingTimer) target.pingTimer = undefined + if (hooks.onDisconnect) { + try { + hooks.onDisconnect(source) + } catch (err) { + options.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onDisconnect hook') + } + } + // reconnect target as long as the source connection is active if (source.isAlive && (target.broken || options.reconnectOnClose)) { // clean up the target and related source listeners @@ -188,7 +199,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks closeWebSocket(target, code, reason) } - function removeSourceListeners (source) { + function removeSourceListeners(source) { source.off('message', sourceOnMessage) source.off('ping', sourceOnPing) source.off('pong', sourceOnPong) @@ -198,38 +209,58 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks } /* c8 ignore start */ - async function sourceOnMessage (data, binary) { + function sourceOnMessage(data, binary) { source.isAlive = true - if (hooks.onTargetRequest) { + if (hooks.onIncomingMessage) { try { - await hooks.onTargetRequest({ data, binary }) + hooks.onIncomingMessage({ data, binary }) } catch (err) { - logger.error({ target: targetParams.url, err }, 'proxy ws error from onTargetRequest hook') + logger.error({ target: targetParams.url, err }, 'proxy ws error from onIncomingMessage hook') } } waitConnection(target, () => target.send(data, { binary })) } - function sourceOnPing (data) { + function sourceOnPing(data) { waitConnection(target, () => target.ping(data)) } - function sourceOnPong (data) { + function sourceOnPong(data) { source.isAlive = true waitConnection(target, () => target.pong(data)) } - function sourceOnClose (code, reason) { + function sourceOnClose(code, reason) { options.logs && logger.warn({ target: targetParams.url, code, reason }, 'proxy ws source close event') close(code, reason) } - function sourceOnError (error) { + function sourceOnError(error) { options.logs && logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws source error event') close(1011, error.message) } - function sourceOnUnexpectedResponse () { + function sourceOnUnexpectedResponse() { options.logs && logger.warn({ target: targetParams.url }, 'proxy ws source unexpected-response event') close(1011, 'unexpected response') } /* c8 ignore stop */ + if (isReconnecting) { + if (hooks.onReconnect) { + waitConnection(target, () => { + try { + hooks.onReconnect(source, target) + } catch (err) { + options.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onReconnect hook') + } + }) + } + } else if (hooks.onConnect) { + waitConnection(target, () => { + try { + hooks.onConnect(source, target) + } catch (err) { + options.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onConnect hook') + } + }) + } + // source is alive since it is created by the proxy service // the pinger is not set since we can't reconnect from here source.isAlive = true @@ -242,13 +273,13 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks // source WebSocket is already connected because it is created by ws server /* c8 ignore start */ - target.on('message', async (data, binary) => { + target.on('message', (data, binary) => { target.isAlive = true - if (hooks.onTargetResponse) { + if (hooks.onOutgoingMessage) { try { - await hooks.onTargetResponse({ data, binary }) + hooks.onOutgoingMessage({ data, binary }) } catch (err) { - logger.error({ target: targetParams.url, err }, 'proxy ws error from onTargetResponse hook') + logger.error({ target: targetParams.url, err }, 'proxy ws error from onOutgoingMessage hook') } } source.send(data, { binary }) @@ -278,6 +309,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks /* c8 ignore stop */ target.isAlive = true + target.pingTimer = setInterval(() => { if (target.isAlive === false) { target.broken = true @@ -291,7 +323,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks }, options.pingInterval).unref() } -function handleUpgrade (fastify, rawRequest, socket, head) { +function handleUpgrade(fastify, rawRequest, socket, head) { // Save a reference to the socket and then dispatch the request through the normal fastify router so that it will invoke hooks and then eventually a route handler that might upgrade the socket. rawRequest[kWs] = socket rawRequest[kWsHead] = head @@ -306,7 +338,7 @@ function handleUpgrade (fastify, rawRequest, socket, head) { } class WebSocketProxy { - constructor (fastify, { wsReconnect, wsHooks, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { + constructor(fastify, { wsReconnect, wsHooks, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { this.logger = fastify.log this.wsClientOptions = { rewriteRequestHeaders: defaultWsHeadersRewrite, @@ -365,7 +397,7 @@ class WebSocketProxy { this.prefixList = [] } - findUpstream (request, dest) { + findUpstream(request, dest) { const { search } = new URL(request.url, 'ws://127.0.0.1') if (typeof this.wsUpstream === 'string' && this.wsUpstream !== '') { @@ -388,7 +420,7 @@ class WebSocketProxy { return target } - handleConnection (source, request, dest) { + handleConnection(source, request, dest) { const url = this.findUpstream(request, dest) const queryString = getQueryString(url.search, request.url, this.wsClientOptions, request) url.search = queryString @@ -416,7 +448,7 @@ class WebSocketProxy { } } -function getQueryString (search, reqUrl, opts, request) { +function getQueryString(search, reqUrl, opts, request) { if (typeof opts.queryString === 'function') { return '?' + opts.queryString(search, reqUrl, request) } @@ -432,14 +464,14 @@ function getQueryString (search, reqUrl, opts, request) { return '' } -function defaultWsHeadersRewrite (headers, request) { +function defaultWsHeadersRewrite(headers, request) { if (request.headers.cookie) { return { ...headers, cookie: request.headers.cookie } } return { ...headers } } -function generateRewritePrefix (prefix, opts) { +function generateRewritePrefix(prefix, opts) { let rewritePrefix = opts.rewritePrefix || (opts.upstream ? new URL(opts.upstream).pathname : '/') if (!prefix.endsWith('/') && rewritePrefix.endsWith('/')) { @@ -449,7 +481,7 @@ function generateRewritePrefix (prefix, opts) { return rewritePrefix } -async function fastifyHttpProxy (fastify, opts) { +async function fastifyHttpProxy(fastify, opts) { opts = validateOptions(opts) const preHandler = opts.preHandler || opts.beforeHandler @@ -475,7 +507,7 @@ async function fastifyHttpProxy (fastify, opts) { fastify.addContentTypeParser('*', bodyParser) } - function rewriteHeaders (headers, req) { + function rewriteHeaders(headers, req) { const location = headers.location if (location && !isExternalUrl(location) && internalRewriteLocationHeader) { headers.location = location.replace(rewritePrefix, fastify.prefix) @@ -486,7 +518,7 @@ async function fastifyHttpProxy (fastify, opts) { return headers } - function bodyParser (_req, payload, done) { + function bodyParser(_req, payload, done) { done(null, payload) } @@ -513,7 +545,7 @@ async function fastifyHttpProxy (fastify, opts) { wsProxy = new WebSocketProxy(fastify, opts) } - function extractUrlComponents (urlString) { + function extractUrlComponents(urlString) { const [path, queryString] = urlString.split('?', 2) const components = { path, @@ -527,7 +559,7 @@ async function fastifyHttpProxy (fastify, opts) { return components } - function handler (request, reply) { + function handler(request, reply) { const { path, queryParams } = extractUrlComponents(request.url) let dest = path diff --git a/src/options.js b/src/options.js index cd332da..6096ded 100644 --- a/src/options.js +++ b/src/options.js @@ -59,18 +59,18 @@ function validateOptions (options) { throw new Error('wsHooks.onReconnect must be a function') } - if (wsHooks.onTargetRequest !== undefined && typeof wsHooks.onTargetRequest !== 'function') { - throw new Error('wsHooks.onTargetRequest must be a function') + if (wsHooks.onIncomingMessage !== undefined && typeof wsHooks.onIncomingMessage !== 'function') { + throw new Error('wsHooks.onIncomingMessage must be a function') } - if (wsHooks.onTargetResponse !== undefined && typeof wsHooks.onTargetResponse !== 'function') { - throw new Error('wsHooks.onTargetResponse must be a function') + if (wsHooks.onOutgoingMessage !== undefined && typeof wsHooks.onOutgoingMessage !== 'function') { + throw new Error('wsHooks.onOutgoingMessage must be a function') } } else { options.wsHooks = { onReconnect: undefined, - onTargetRequest: undefined, - onTargetResponse: undefined + onIncomingMessage: undefined, + onOutgoingMessage: undefined } } diff --git a/test/options.js b/test/options.js index 6c55c08..115d71d 100644 --- a/test/options.js +++ b/test/options.js @@ -46,11 +46,11 @@ test('validateOptions', (t) => { assert.throws(() => validateOptions({ ...requiredOptions, wsHooks: { onReconnect: '1' } }), /wsHooks.onReconnect must be a function/) assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsHooks: { onReconnect: () => { } } })) - assert.throws(() => validateOptions({ ...requiredOptions, wsHooks: { onTargetRequest: '1' } }), /wsHooks.onTargetRequest must be a function/) - assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsHooks: { onTargetRequest: () => { } } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsHooks: { onIncomingMessage: '1' } }), /wsHooks.onIncomingMessage must be a function/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsHooks: { onIncomingMessage: () => { } } })) - assert.throws(() => validateOptions({ ...requiredOptions, wsHooks: { onTargetResponse: '1' } }), /wsHooks.onTargetResponse must be a function/) - assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsHooks: { onTargetResponse: () => { } } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsHooks: { onOutgoingMessage: '1' } }), /wsHooks.onOutgoingMessage must be a function/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsHooks: { onOutgoingMessage: () => { } } })) // set all values assert.doesNotThrow(() => validateOptions({ @@ -66,8 +66,8 @@ test('validateOptions', (t) => { }, wsHooks: { onReconnect: () => { }, - onTargetRequest: () => { }, - onTargetResponse: () => { } + onIncomingMessage: () => { }, + onOutgoingMessage: () => { } } })) @@ -85,8 +85,8 @@ test('validateOptions', (t) => { }, wsHooks: { onReconnect: undefined, - onTargetRequest: undefined, - onTargetResponse: undefined + onIncomingMessage: undefined, + onOutgoingMessage: undefined } }) }) diff --git a/test/websocket.js b/test/websocket.js index 1e31b10..d6669a8 100644 --- a/test/websocket.js +++ b/test/websocket.js @@ -713,21 +713,21 @@ test('multiple websocket upstreams with distinct server options', async (t) => { ]) }) -test('should call onTargetRequest and onTargetResponse hooks', async (t) => { +test('should call onIncomingMessage and onOutgoingMessage hooks', async (t) => { const request = 'query () { ... }' const response = 'data ...' - const onTargetRequest = ({ data, binary }) => { + const onIncomingMessage = ({ data, binary }) => { assert.strictEqual(data.toString(), request) assert.strictEqual(binary, false) - logger.info('onTargetRequest called') + logger.info('onIncomingMessage called') } - const onTargetResponse = ({ data, binary }) => { + const onOutgoingMessage = ({ data, binary }) => { assert.strictEqual(data.toString(), response) assert.strictEqual(binary, false) - logger.info('onTargetResponse called') + logger.info('onOutgoingMessage called') } - const { target, loggerSpy, logger, client } = await createServices({ t, wsHooks: { onTargetRequest, onTargetResponse } }) + const { target, loggerSpy, logger, client } = await createServices({ t, wsHooks: { onIncomingMessage, onOutgoingMessage } }) target.ws.on('connection', async (socket) => { socket.on('message', async (data, binary) => { @@ -737,25 +737,25 @@ test('should call onTargetRequest and onTargetResponse hooks', async (t) => { client.send(request) - await waitForLogMessage(loggerSpy, 'onTargetRequest called') - await waitForLogMessage(loggerSpy, 'onTargetResponse called') + await waitForLogMessage(loggerSpy, 'onIncomingMessage called') + await waitForLogMessage(loggerSpy, 'onOutgoingMessage called') }) -test('should handle throwing an error in onTargetRequest and onTargetResponse hooks', async (t) => { +test('should handle throwing an error in onIncomingMessage and onOutgoingMessage hooks', async (t) => { const request = 'query () { ... }' const response = 'data ...' - const onTargetRequest = ({ data, binary }) => { + const onIncomingMessage = ({ data, binary }) => { assert.strictEqual(data.toString(), request) assert.strictEqual(binary, false) - throw new Error('onTargetRequest error') + throw new Error('onIncomingMessage error') } - const onTargetResponse = ({ data, binary }) => { + const onOutgoingMessage = ({ data, binary }) => { assert.strictEqual(data.toString(), response) assert.strictEqual(binary, false) - throw new Error('onTargetResponse error') + throw new Error('onOutgoingMessage error') } - const { target, loggerSpy, client } = await createServices({ t, wsHooks: { onTargetRequest, onTargetResponse } }) + const { target, loggerSpy, client } = await createServices({ t, wsHooks: { onIncomingMessage, onOutgoingMessage } }) target.ws.on('connection', async (socket) => { socket.on('message', async (data, binary) => { @@ -765,6 +765,8 @@ test('should handle throwing an error in onTargetRequest and onTargetResponse ho client.send(request) - await waitForLogMessage(loggerSpy, 'proxy ws error from onTargetRequest hook') - await waitForLogMessage(loggerSpy, 'proxy ws error from onTargetResponse hook') + await waitForLogMessage(loggerSpy, 'proxy ws error from onIncomingMessage hook') + await waitForLogMessage(loggerSpy, 'proxy ws error from onOutgoingMessage hook') }) + +// TODO onConnect, onDisconnect diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index 05351c6..db4ab7f 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -200,18 +200,18 @@ test('should handle throwing an error in onReconnect hook', async (t) => { await waitForLogMessage(loggerSpy, 'proxy ws error from onReconnect hook') }) -test('should call onTargetRequest and onTargetResponse hooks, with reconnection', async (t) => { +test('should call onIncomingMessage and onOutgoingMessage hooks, with reconnection', async (t) => { const request = 'query () { ... }' const response = 'data ...' - const onTargetRequest = ({ data, binary }) => { + const onIncomingMessage = ({ data, binary }) => { assert.strictEqual(data.toString(), request) assert.strictEqual(binary, false) - logger.info('onTargetRequest called') + logger.info('onIncomingMessage called') } - const onTargetResponse = ({ data, binary }) => { + const onOutgoingMessage = ({ data, binary }) => { assert.strictEqual(data.toString(), response) assert.strictEqual(binary, false) - logger.info('onTargetResponse called') + logger.info('onOutgoingMessage called') } const wsReconnectOptions = { pingInterval: 100, @@ -220,7 +220,7 @@ test('should call onTargetRequest and onTargetResponse hooks, with reconnection' logs: true, } - const { target, loggerSpy, logger, client } = await createServices({ t, wsReconnectOptions, wsHooks: { onTargetRequest, onTargetResponse } }) + const { target, loggerSpy, logger, client } = await createServices({ t, wsReconnectOptions, wsHooks: { onIncomingMessage, onOutgoingMessage } }) target.ws.on('connection', async (socket) => { socket.on('message', async (data, binary) => { @@ -230,22 +230,22 @@ test('should call onTargetRequest and onTargetResponse hooks, with reconnection' client.send(request) - await waitForLogMessage(loggerSpy, 'onTargetRequest called') - await waitForLogMessage(loggerSpy, 'onTargetResponse called') + await waitForLogMessage(loggerSpy, 'onIncomingMessage called') + await waitForLogMessage(loggerSpy, 'onOutgoingMessage called') }) -test('should handle throwing an error in onTargetRequest and onTargetResponse hooks, with reconnection', async (t) => { +test('should handle throwing an error in onIncomingMessage and onOutgoingMessage hooks, with reconnection', async (t) => { const request = 'query () { ... }' const response = 'data ...' - const onTargetRequest = ({ data, binary }) => { + const onIncomingMessage = ({ data, binary }) => { assert.strictEqual(data.toString(), request) assert.strictEqual(binary, false) - throw new Error('onTargetRequest error') + throw new Error('onIncomingMessage error') } - const onTargetResponse = ({ data, binary }) => { + const onOutgoingMessage = ({ data, binary }) => { assert.strictEqual(data.toString(), response) assert.strictEqual(binary, false) - throw new Error('onTargetResponse error') + throw new Error('onOutgoingMessage error') } const wsReconnectOptions = { pingInterval: 100, @@ -254,7 +254,7 @@ test('should handle throwing an error in onTargetRequest and onTargetResponse ho logs: true, } - const { target, loggerSpy, client } = await createServices({ t, wsReconnectOptions, wsHooks: { onTargetRequest, onTargetResponse } }) + const { target, loggerSpy, client } = await createServices({ t, wsReconnectOptions, wsHooks: { onIncomingMessage, onOutgoingMessage } }) target.ws.on('connection', async (socket) => { socket.on('message', async (data, binary) => { @@ -264,6 +264,8 @@ test('should handle throwing an error in onTargetRequest and onTargetResponse ho client.send(request) - await waitForLogMessage(loggerSpy, 'proxy ws error from onTargetRequest hook') - await waitForLogMessage(loggerSpy, 'proxy ws error from onTargetResponse hook') + await waitForLogMessage(loggerSpy, 'proxy ws error from onIncomingMessage hook') + await waitForLogMessage(loggerSpy, 'proxy ws error from onOutgoingMessage hook') }) + +// TODO onConnect, onDisconnect From 2610677c90140bfedee67ef4a0e69a5b5b908a57 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Tue, 18 Feb 2025 10:46:16 +0100 Subject: [PATCH 21/25] wip --- README.md | 2 ++ index.js | 59 ++++++++++++++++++++++++++----------------------------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index c0a0a1f..cce4b85 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,8 @@ To enable the feature, set the `wsReconnect` option to an object with the follow ## wsHooks +On websocket events, the following hooks are available, note **the hooks are all synchronous**. + - `onConnect`: A hook function that is called when the connection is established `onConnect(source, target)` (default: `undefined`). - `onDisconnect`: A hook function that is called when the connection is closed `onDisconnect(source)` (default: `undefined`). - `onReconnect`: A hook function that is called when the connection is reconnected `onReconnect(source, target)` (default: `undefined`). diff --git a/index.js b/index.js index 14b2e8d..534cf77 100644 --- a/index.js +++ b/index.js @@ -173,6 +173,7 @@ function proxyWebSocketsWithReconnection(logger, source, target, options, hooks, function close(code, reason) { target.pingTimer && clearInterval(target.pingTimer) target.pingTimer = undefined + closeWebSocket(target, code, reason) if (hooks.onDisconnect) { try { @@ -241,26 +242,6 @@ function proxyWebSocketsWithReconnection(logger, source, target, options, hooks, } /* c8 ignore stop */ - if (isReconnecting) { - if (hooks.onReconnect) { - waitConnection(target, () => { - try { - hooks.onReconnect(source, target) - } catch (err) { - options.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onReconnect hook') - } - }) - } - } else if (hooks.onConnect) { - waitConnection(target, () => { - try { - hooks.onConnect(source, target) - } catch (err) { - options.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onConnect hook') - } - }) - } - // source is alive since it is created by the proxy service // the pinger is not set since we can't reconnect from here source.isAlive = true @@ -308,19 +289,35 @@ function proxyWebSocketsWithReconnection(logger, source, target, options, hooks, }) /* c8 ignore stop */ - target.isAlive = true + waitConnection(target, () => { + target.isAlive = true + target.pingTimer = setInterval(() => { + if (target.isAlive === false) { + target.broken = true + options.logs && logger.warn({ target: targetParams.url }, 'proxy ws connection is broken') + target.pingTimer && clearInterval(target.pingTimer) + target.pingTimer = undefined + return target.terminate() + } + target.isAlive = false + target.ping() + }, options.pingInterval).unref() - target.pingTimer = setInterval(() => { - if (target.isAlive === false) { - target.broken = true - options.logs && logger.warn({ target: targetParams.url }, 'proxy ws connection is broken') - target.pingTimer && clearInterval(target.pingTimer) - target.pingTimer = undefined - return target.terminate() + // call onConnect and onReconnect callbacks after the events are bound + if (isReconnecting && hooks.onReconnect) { + try { + hooks.onReconnect(source, target) + } catch (err) { + options.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onReconnect hook') + } + } else if (hooks.onConnect) { + try { + hooks.onConnect(source, target) + } catch (err) { + options.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onConnect hook') + } } - target.isAlive = false - target.ping() - }, options.pingInterval).unref() + }) } function handleUpgrade(fastify, rawRequest, socket, head) { From 15e6ba8fa852991551d419cb4cbf26cccee3d1d5 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Tue, 18 Feb 2025 10:47:27 +0100 Subject: [PATCH 22/25] wip --- examples/ws-reconnection.js | 2 +- index.js | 62 ++++++++++++++++++------------------- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/examples/ws-reconnection.js b/examples/ws-reconnection.js index 0ffdd02..70b786d 100644 --- a/examples/ws-reconnection.js +++ b/examples/ws-reconnection.js @@ -1 +1 @@ -// TODO \ No newline at end of file +// TODO diff --git a/index.js b/index.js index 534cf77..5120ead 100644 --- a/index.js +++ b/index.js @@ -14,7 +14,7 @@ const kWs = Symbol('ws') const kWsHead = Symbol('wsHead') const kWsUpgradeListener = Symbol('wsUpgradeListener') -function liftErrorCode(code) { +function liftErrorCode (code) { /* c8 ignore start */ if (typeof code !== 'number') { // Sometimes "close" event emits with a non-numeric value @@ -28,14 +28,14 @@ function liftErrorCode(code) { /* c8 ignore stop */ } -function closeWebSocket(socket, code, reason) { +function closeWebSocket (socket, code, reason) { socket.isAlive = false if (socket.readyState === WebSocket.OPEN) { socket.close(liftErrorCode(code), reason) } } -function waitConnection(socket, write) { +function waitConnection (socket, write) { if (socket.readyState === WebSocket.CONNECTING) { socket.once('open', write) } else { @@ -43,7 +43,7 @@ function waitConnection(socket, write) { } } -function waitForConnection(target, timeout) { +function waitForConnection (target, timeout) { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { /* c8 ignore start */ @@ -76,14 +76,14 @@ function waitForConnection(target, timeout) { }) } -function isExternalUrl(url) { +function isExternalUrl (url) { return urlPattern.test(url) } -function noop() { } +function noop () { } -function proxyWebSockets(logger, source, target, hooks) { - function close(code, reason) { +function proxyWebSockets (logger, source, target, hooks) { + function close (code, reason) { closeWebSocket(source, code, reason) closeWebSocket(target, code, reason) } @@ -134,13 +134,13 @@ function proxyWebSockets(logger, source, target, hooks) { try { hooks.onConnect(source, target) } catch (err) { - logger.error({ target: targetParams.url, err }, 'proxy ws error from onConnect hook') + logger.error({ err }, 'proxy ws error from onConnect hook') } }) } } -async function reconnect(logger, source, reconnectOptions, hooks, targetParams) { +async function reconnect (logger, source, reconnectOptions, hooks, targetParams) { const { url, subprotocols, optionsWs } = targetParams let attempts = 0 @@ -169,8 +169,8 @@ async function reconnect(logger, source, reconnectOptions, hooks, targetParams) proxyWebSocketsWithReconnection(logger, source, target, reconnectOptions, hooks, targetParams, true) } -function proxyWebSocketsWithReconnection(logger, source, target, options, hooks, targetParams, isReconnecting = false) { - function close(code, reason) { +function proxyWebSocketsWithReconnection (logger, source, target, options, hooks, targetParams, isReconnecting = false) { + function close (code, reason) { target.pingTimer && clearInterval(target.pingTimer) target.pingTimer = undefined closeWebSocket(target, code, reason) @@ -200,7 +200,7 @@ function proxyWebSocketsWithReconnection(logger, source, target, options, hooks, closeWebSocket(target, code, reason) } - function removeSourceListeners(source) { + function removeSourceListeners (source) { source.off('message', sourceOnMessage) source.off('ping', sourceOnPing) source.off('pong', sourceOnPong) @@ -210,7 +210,7 @@ function proxyWebSocketsWithReconnection(logger, source, target, options, hooks, } /* c8 ignore start */ - function sourceOnMessage(data, binary) { + function sourceOnMessage (data, binary) { source.isAlive = true if (hooks.onIncomingMessage) { try { @@ -221,22 +221,22 @@ function proxyWebSocketsWithReconnection(logger, source, target, options, hooks, } waitConnection(target, () => target.send(data, { binary })) } - function sourceOnPing(data) { + function sourceOnPing (data) { waitConnection(target, () => target.ping(data)) } - function sourceOnPong(data) { + function sourceOnPong (data) { source.isAlive = true waitConnection(target, () => target.pong(data)) } - function sourceOnClose(code, reason) { + function sourceOnClose (code, reason) { options.logs && logger.warn({ target: targetParams.url, code, reason }, 'proxy ws source close event') close(code, reason) } - function sourceOnError(error) { + function sourceOnError (error) { options.logs && logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws source error event') close(1011, error.message) } - function sourceOnUnexpectedResponse() { + function sourceOnUnexpectedResponse () { options.logs && logger.warn({ target: targetParams.url }, 'proxy ws source unexpected-response event') close(1011, 'unexpected response') } @@ -320,7 +320,7 @@ function proxyWebSocketsWithReconnection(logger, source, target, options, hooks, }) } -function handleUpgrade(fastify, rawRequest, socket, head) { +function handleUpgrade (fastify, rawRequest, socket, head) { // Save a reference to the socket and then dispatch the request through the normal fastify router so that it will invoke hooks and then eventually a route handler that might upgrade the socket. rawRequest[kWs] = socket rawRequest[kWsHead] = head @@ -335,7 +335,7 @@ function handleUpgrade(fastify, rawRequest, socket, head) { } class WebSocketProxy { - constructor(fastify, { wsReconnect, wsHooks, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { + constructor (fastify, { wsReconnect, wsHooks, wsServerOptions, wsClientOptions, upstream, wsUpstream, replyOptions: { getUpstream } = {} }) { this.logger = fastify.log this.wsClientOptions = { rewriteRequestHeaders: defaultWsHeadersRewrite, @@ -394,7 +394,7 @@ class WebSocketProxy { this.prefixList = [] } - findUpstream(request, dest) { + findUpstream (request, dest) { const { search } = new URL(request.url, 'ws://127.0.0.1') if (typeof this.wsUpstream === 'string' && this.wsUpstream !== '') { @@ -417,7 +417,7 @@ class WebSocketProxy { return target } - handleConnection(source, request, dest) { + handleConnection (source, request, dest) { const url = this.findUpstream(request, dest) const queryString = getQueryString(url.search, request.url, this.wsClientOptions, request) url.search = queryString @@ -445,7 +445,7 @@ class WebSocketProxy { } } -function getQueryString(search, reqUrl, opts, request) { +function getQueryString (search, reqUrl, opts, request) { if (typeof opts.queryString === 'function') { return '?' + opts.queryString(search, reqUrl, request) } @@ -461,14 +461,14 @@ function getQueryString(search, reqUrl, opts, request) { return '' } -function defaultWsHeadersRewrite(headers, request) { +function defaultWsHeadersRewrite (headers, request) { if (request.headers.cookie) { return { ...headers, cookie: request.headers.cookie } } return { ...headers } } -function generateRewritePrefix(prefix, opts) { +function generateRewritePrefix (prefix, opts) { let rewritePrefix = opts.rewritePrefix || (opts.upstream ? new URL(opts.upstream).pathname : '/') if (!prefix.endsWith('/') && rewritePrefix.endsWith('/')) { @@ -478,7 +478,7 @@ function generateRewritePrefix(prefix, opts) { return rewritePrefix } -async function fastifyHttpProxy(fastify, opts) { +async function fastifyHttpProxy (fastify, opts) { opts = validateOptions(opts) const preHandler = opts.preHandler || opts.beforeHandler @@ -504,7 +504,7 @@ async function fastifyHttpProxy(fastify, opts) { fastify.addContentTypeParser('*', bodyParser) } - function rewriteHeaders(headers, req) { + function rewriteHeaders (headers, req) { const location = headers.location if (location && !isExternalUrl(location) && internalRewriteLocationHeader) { headers.location = location.replace(rewritePrefix, fastify.prefix) @@ -515,7 +515,7 @@ async function fastifyHttpProxy(fastify, opts) { return headers } - function bodyParser(_req, payload, done) { + function bodyParser (_req, payload, done) { done(null, payload) } @@ -542,7 +542,7 @@ async function fastifyHttpProxy(fastify, opts) { wsProxy = new WebSocketProxy(fastify, opts) } - function extractUrlComponents(urlString) { + function extractUrlComponents (urlString) { const [path, queryString] = urlString.split('?', 2) const components = { path, @@ -556,7 +556,7 @@ async function fastifyHttpProxy(fastify, opts) { return components } - function handler(request, reply) { + function handler (request, reply) { const { path, queryParams } = extractUrlComponents(request.url) let dest = path From bd357033afc4f72ff2e06f8eae81034a6dbd5e50 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Tue, 18 Feb 2025 10:59:43 +0100 Subject: [PATCH 23/25] add tests --- index.js | 9 +++++++ test/websocket.js | 42 +++++++++++++++++++++++++++++++- test/ws-reconnect.js | 58 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 5120ead..f911ab6 100644 --- a/index.js +++ b/index.js @@ -84,6 +84,15 @@ function noop () { } function proxyWebSockets (logger, source, target, hooks) { function close (code, reason) { + if (hooks.onDisconnect) { + waitConnection(target, () => { + try { + hooks.onDisconnect(source) + } catch (err) { + logger.error({ err }, 'proxy ws error from onDisconnect hook') + } + }) + } closeWebSocket(source, code, reason) closeWebSocket(target, code, reason) } diff --git a/test/websocket.js b/test/websocket.js index d6669a8..b2a7c18 100644 --- a/test/websocket.js +++ b/test/websocket.js @@ -769,4 +769,44 @@ test('should handle throwing an error in onIncomingMessage and onOutgoingMessage await waitForLogMessage(loggerSpy, 'proxy ws error from onOutgoingMessage hook') }) -// TODO onConnect, onDisconnect +test('should call onConnect hook', async (t) => { + const onConnect = () => { + logger.info('onConnect called') + } + + const { loggerSpy, logger } = await createServices({ t, wsHooks: { onConnect } }) + + await waitForLogMessage(loggerSpy, 'onConnect called') +}) + +test('should handle throwing an error in onConnect hook', async (t) => { + const onConnect = () => { + throw new Error('onConnect error') + } + + const { loggerSpy } = await createServices({ t, wsHooks: { onConnect } }) + + await waitForLogMessage(loggerSpy, 'proxy ws error from onConnect hook') +}) + +test('should call onDisconnect hook', async (t) => { + const onDisconnect = () => { + logger.info('onDisconnect called') + } + + const { loggerSpy, logger, client } = await createServices({ t, wsHooks: { onDisconnect } }) + client.close() + + await waitForLogMessage(loggerSpy, 'onDisconnect called') +}) + +test('should handle throwing an error in onDisconnect hook', async (t) => { + const onDisconnect = () => { + throw new Error('onDisconnect error') + } + + const { loggerSpy, client } = await createServices({ t, wsHooks: { onDisconnect } }) + client.close() + + await waitForLogMessage(loggerSpy, 'proxy ws error from onDisconnect hook') +}) diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index db4ab7f..c86a611 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -268,4 +268,60 @@ test('should handle throwing an error in onIncomingMessage and onOutgoingMessage await waitForLogMessage(loggerSpy, 'proxy ws error from onOutgoingMessage hook') }) -// TODO onConnect, onDisconnect +test('should call onConnect hook', async (t) => { + const onConnect = () => { + logger.info('onConnect called') + } + + const wsReconnectOptions = { + logs: true, + } + + const { loggerSpy, logger } = await createServices({ t, wsReconnectOptions, wsHooks: { onConnect } }) + + await waitForLogMessage(loggerSpy, 'onConnect called') +}) + +test('should handle throwing an error in onConnect hook', async (t) => { + const onConnect = () => { + throw new Error('onConnect error') + } + + const wsReconnectOptions = { + logs: true, + } + + const { loggerSpy } = await createServices({ t, wsReconnectOptions, wsHooks: { onConnect } }) + + await waitForLogMessage(loggerSpy, 'proxy ws error from onConnect hook') +}) + +test('should call onDisconnect hook', async (t) => { + const onDisconnect = () => { + logger.info('onDisconnect called') + } + + const wsReconnectOptions = { + logs: true, + } + + const { loggerSpy, logger, client } = await createServices({ t, wsReconnectOptions, wsHooks: { onDisconnect } }) + client.close() + + await waitForLogMessage(loggerSpy, 'onDisconnect called') +}) + +test('should handle throwing an error in onDisconnect hook', async (t) => { + const onDisconnect = () => { + throw new Error('onDisconnect error') + } + + const wsReconnectOptions = { + logs: true, + } + + const { loggerSpy, client } = await createServices({ t, wsReconnectOptions, wsHooks: { onDisconnect } }) + client.close() + + await waitForLogMessage(loggerSpy, 'proxy ws error from onDisconnect hook') +}) From b0f65c2b5bca83c662d727edb1e6f9af23d6e2f6 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Tue, 18 Feb 2025 17:14:23 +0100 Subject: [PATCH 24/25] add reconnection example --- README.md | 9 ++- examples/reconnection/ReconnectionExample.md | 60 +++++++++++++++ examples/reconnection/client/index.js | 75 +++++++++++++++++++ examples/reconnection/client/package.json | 12 +++ examples/reconnection/proxy/index.js | 73 ++++++++++++++++++ examples/reconnection/proxy/package.json | 12 +++ .../reconnection/unstable-target/index.js | 75 +++++++++++++++++++ .../reconnection/unstable-target/package.json | 14 ++++ examples/ws-reconnection.js | 1 - index.js | 15 +++- 10 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 examples/reconnection/ReconnectionExample.md create mode 100644 examples/reconnection/client/index.js create mode 100644 examples/reconnection/client/package.json create mode 100644 examples/reconnection/proxy/index.js create mode 100644 examples/reconnection/proxy/package.json create mode 100644 examples/reconnection/unstable-target/index.js create mode 100644 examples/reconnection/unstable-target/package.json delete mode 100644 examples/ws-reconnection.js diff --git a/README.md b/README.md index cce4b85..9720f17 100644 --- a/README.md +++ b/README.md @@ -245,15 +245,18 @@ To enable the feature, set the `wsReconnect` option to an object with the follow - `reconnectOnClose`: Whether to reconnect on close, as long as the connection from the related client to the proxy is active (default: `false`). - `logs`: Whether to log the reconnection process (default: `false`). +See the example in [examples/reconnection](examples/reconnection). + ## wsHooks On websocket events, the following hooks are available, note **the hooks are all synchronous**. -- `onConnect`: A hook function that is called when the connection is established `onConnect(source, target)` (default: `undefined`). -- `onDisconnect`: A hook function that is called when the connection is closed `onDisconnect(source)` (default: `undefined`). -- `onReconnect`: A hook function that is called when the connection is reconnected `onReconnect(source, target)` (default: `undefined`). - `onIncomingMessage`: A hook function that is called when the request is received from the client `onIncomingMessage({ data, binary })` (default: `undefined`). - `onOutgoingMessage`: A hook function that is called when the response is received from the target `onOutgoingMessage({ data, binary })` (default: `undefined`). +- `onConnect`: A hook function that is called when the connection is established `onConnect(source, target)` (default: `undefined`). +- `onDisconnect`: A hook function that is called when the connection is closed `onDisconnect(source)` (default: `undefined`). +- `onReconnect`: A hook function that is called when the connection is reconnected `onReconnect(source, target)` (default: `undefined`). The function is called if reconnection feature is enabled. +- `onPong`: A hook function that is called when the target responds to the ping `onPong(source, target)` (default: `undefined`). The function is called if reconnection feature is enabled. ## Benchmarks diff --git a/examples/reconnection/ReconnectionExample.md b/examples/reconnection/ReconnectionExample.md new file mode 100644 index 0000000..675e939 --- /dev/null +++ b/examples/reconnection/ReconnectionExample.md @@ -0,0 +1,60 @@ +# Reconnection Example + +This example demonstrates how to use the reconnection feature of the proxy. + +It simulates an unstable target service: slow to start, unresponsive due to block of the event loop, crash and restart. + +The goal is to ensures a more resilient and customizable integration, minimizing disruptions caused by connection instability. + + +## How to run + +Run the unstable target + +``` +cd examples/reconnection/unstable-target +npm run unstable +``` + +Run the proxy + +``` +cd examples/reconnection/proxy +npm run start +``` + +Then run the client + +``` +cd examples/reconnection/client +npm run start +``` + +--- + +## How it works + +### Proxy Connection Monitoring and Recovery + +The proxy monitors the target connection using a ping/pong mechanism. If a pong response does not arrive on time, the connection is closed, and the proxy attempts to reconnect. + +If the target service crashes, the connection may close either gracefully or abruptly. Regardless of how the disconnection occurs, the proxy detects the connection loss and initiates a reconnection attempt. + +### Connection Stability + +- The connection between the client and the proxy remains unaffected by an unstable target. +- The connection between the proxy and the target may be closed due to: +- The target failing to respond to ping messages, even if the connection is still technically open (e.g., due to a freeze or blockage). +- The target crashing and restarting. + +### Handling Data Loss During Reconnection + +The proxy supports hooks to manage potential data loss during reconnection. These hooks allow for custom logic to ensure message integrity when resending data from the client to the target. + +Examples of how hooks can be used based on the target service type: + +- GraphQL subscriptions: Resend the subscription from the last received message. +- Message brokers: Resend messages starting from the last successfully processed message. + +In this example, the proxy re-sends the messages from the last ping to ensure all the messages are sent to the target, without any additional logic. +Resending messages from the last pong ensures that the target does not miss any messages, but it may send messages more than once. diff --git a/examples/reconnection/client/index.js b/examples/reconnection/client/index.js new file mode 100644 index 0000000..deff07a --- /dev/null +++ b/examples/reconnection/client/index.js @@ -0,0 +1,75 @@ +'use strict' + +const WebSocket = require('ws') + +const port = process.env.PORT || 3001 + +// connect to proxy + +const url = `ws://localhost:${port}/` +const ws = new WebSocket(url) +const client = WebSocket.createWebSocketStream(ws, { encoding: 'utf8', objectMode: true }) + +client.setEncoding('utf8') + +let i = 1 +setInterval(() => { + client.write(JSON.stringify({ + message: i + })) + i++ +}, 1000).unref() +const responses = {} + +client.on('data', message => { + const data = JSON.parse(message) + console.log('Received', data) + responses[data.response] = responses[data.response] ? responses[data.response] + 1 : 1 +}) + +client.on('error', error => { + console.log('Error') + console.error(error) +}) + +client.on('close', () => { + console.log('\n\n\nConnection closed') + + console.log('\n\n\nResponses') + for (const key in responses) { + if (!responses[key]) { + console.log('missing', key) + } else if (responses[key] !== 1) { + console.log('extra messages', key, responses[key]) + } + } +}) + +client.on('unexpected-response', (error) => { + console.log('Unexpected response') + console.error(error) +}) + +client.on('redirect', (error) => { + console.log('Redirect') + console.error(error) +}) + +client.on('upgrade', (error) => { + console.log('Upgrade') + console.error(error) +}) + +client.on('ping', (error) => { + console.log('Ping') + console.error(error) +}) + +client.on('pong', (error) => { + console.log('Pong') + console.error(error) +}) + +process.on('SIGINT', () => { + client.end() +}) diff --git a/examples/reconnection/client/package.json b/examples/reconnection/client/package.json new file mode 100644 index 0000000..0a0810a --- /dev/null +++ b/examples/reconnection/client/package.json @@ -0,0 +1,12 @@ +{ + "name": "client", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/examples/reconnection/proxy/index.js b/examples/reconnection/proxy/index.js new file mode 100644 index 0000000..6a49def --- /dev/null +++ b/examples/reconnection/proxy/index.js @@ -0,0 +1,73 @@ +'use strict' + +const { setTimeout: wait } = require('node:timers/promises') +const fastify = require('fastify') +const fastifyHttpProxy = require('../../../') + +async function main () { + const port = process.env.PORT || 3001 + + const wsReconnect = { + logs: true, + pingInterval: 3_000, + reconnectOnClose: true, + } + + let backup = [] + let lastPong = Date.now() + + // resend messages from last ping + // it may send messages more than once + // in case the target already received messages between last ping and the reconnection + async function resendMessages (target) { + const now = Date.now() + + for (const m of backup) { + if (m.timestamp < lastPong || m.timestamp > now) { + continue + } + console.log(' >>> resending message #', m) + target.send(m.message) + // introduce a small delay to avoid to flood the target + await wait(250) + } + }; + + const wsHooks = { + onPong: () => { + console.log('onPong') + lastPong = Date.now() + // clean backup from the last ping + backup = backup.filter(message => message.timestamp > lastPong) + }, + onIncomingMessage: (message) => { + const m = message.data.toString() + console.log('onIncomingMessage backup', m) + backup.push({ message: m, timestamp: Date.now() }) + }, + // onOutgoingMessage: (message) => { + // console.log('onOutgoingMessage', message.data.toString()) + // }, + onDisconnect: () => { + console.log('onDisconnect') + backup.length = 0 + }, + onReconnect: (source, target) => { + console.log('onReconnect') + resendMessages(target) + }, + } + + const proxy = fastify({ logger: true }) + proxy.register(fastifyHttpProxy, { + upstream: 'http://localhost:3000/', + websocket: true, + wsUpstream: 'ws://localhost:3000/', + wsReconnect, + wsHooks, + }) + + await proxy.listen({ port }) +} + +main() diff --git a/examples/reconnection/proxy/package.json b/examples/reconnection/proxy/package.json new file mode 100644 index 0000000..450fb2b --- /dev/null +++ b/examples/reconnection/proxy/package.json @@ -0,0 +1,12 @@ +{ + "name": "proxy", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js" + }, + "dependencies": { + "fastify": "^5.2.1" + } +} diff --git a/examples/reconnection/unstable-target/index.js b/examples/reconnection/unstable-target/index.js new file mode 100644 index 0000000..3000049 --- /dev/null +++ b/examples/reconnection/unstable-target/index.js @@ -0,0 +1,75 @@ +'use strict' + +const { setTimeout: wait } = require('node:timers/promises') +const fastify = require('fastify') + +// unstable service + +async function main () { + const SLOW_START = process.env.SLOW_START || 2_000 + const UNSTABLE_MIN = process.env.UNSTABLE_MIN || 1_000 + const UNSTABLE_MAX = process.env.UNSTABLE_MAX || 10_000 + const BLOCK_TIME = process.env.BLOCK_TIME || 5_000 + + const app = fastify({ logger: true }) + + // slow start + + await wait(SLOW_START) + + app.register(require('@fastify/websocket')) + app.register(async function (app) { + app.get('/', { websocket: true }, (socket) => { + socket.on('message', message => { + let m = message.toString() + console.log('incoming message', m) + m = JSON.parse(m) + + socket.send(JSON.stringify({ + response: m.message + })) + }) + }) + }) + + try { + const port = process.env.PORT || 3000 + await app.listen({ port }) + } catch (err) { + app.log.error(err) + process.exit(1) + } + + if (process.env.STABLE) { + return + } + + function runProblem () { + const problem = process.env.PROBLEM || (Math.random() < 0.5 ? 'crash' : 'block') + const unstabilityTimeout = process.env.UNSTABLE_TIMEOUT || Math.round(UNSTABLE_MIN + Math.random() * (UNSTABLE_MAX - UNSTABLE_MIN)) + + if (problem === 'crash') { + console.log(`Restarting (crash and restart) in ${unstabilityTimeout}ms`) + setTimeout(() => { + console.log('UNHANDLED EXCEPTION') + throw new Error('UNHANDLED EXCEPTION') + }, unstabilityTimeout).unref() + } else { + console.log(`Blocking EL in ${unstabilityTimeout}ms for ${BLOCK_TIME}ms`) + + setTimeout(() => { + console.log('Block EL ...') + const start = performance.now() + while (performance.now() - start < BLOCK_TIME) { + // just block + } + console.log('Block ends') + runProblem() + }, unstabilityTimeout).unref() + } + } + + runProblem() +} + +main() diff --git a/examples/reconnection/unstable-target/package.json b/examples/reconnection/unstable-target/package.json new file mode 100644 index 0000000..4a33bd8 --- /dev/null +++ b/examples/reconnection/unstable-target/package.json @@ -0,0 +1,14 @@ +{ + "name": "unstable-target", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "stable": "STABLE=1 node index.js", + "unstable": "forever index.js", + "dev": "node --watch index.js" + }, + "dependencies": { + "fastify": "^5.2.1", + "forever": "^4.0.3" + } +} diff --git a/examples/ws-reconnection.js b/examples/ws-reconnection.js deleted file mode 100644 index 70b786d..0000000 --- a/examples/ws-reconnection.js +++ /dev/null @@ -1 +0,0 @@ -// TODO diff --git a/index.js b/index.js index f911ab6..76abd9c 100644 --- a/index.js +++ b/index.js @@ -197,8 +197,6 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks // clean up the target and related source listeners target.isAlive = false target.removeAllListeners() - // need to specify the listeners to remove - removeSourceListeners(source) reconnect(logger, source, options, hooks, targetParams) return @@ -231,6 +229,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks waitConnection(target, () => target.send(data, { binary })) } function sourceOnPing (data) { + source.isAlive = true waitConnection(target, () => target.ping(data)) } function sourceOnPong (data) { @@ -238,19 +237,24 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks waitConnection(target, () => target.pong(data)) } function sourceOnClose (code, reason) { + source.isAlive = false options.logs && logger.warn({ target: targetParams.url, code, reason }, 'proxy ws source close event') close(code, reason) } function sourceOnError (error) { + source.isAlive = false options.logs && logger.warn({ target: targetParams.url, error: error.message }, 'proxy ws source error event') close(1011, error.message) } function sourceOnUnexpectedResponse () { + source.isAlive = false options.logs && logger.warn({ target: targetParams.url }, 'proxy ws source unexpected-response event') close(1011, 'unexpected response') } /* c8 ignore stop */ + // need to specify the listeners to remove + removeSourceListeners(source) // source is alive since it is created by the proxy service // the pinger is not set since we can't reconnect from here source.isAlive = true @@ -280,6 +284,13 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks }) target.on('pong', data => { target.isAlive = true + if (hooks.onPong) { + try { + hooks.onPong(source, target) + } catch (err) { + logger.error({ target: targetParams.url, err }, 'proxy ws error from onPong hook') + } + } source.pong(data) }) /* c8 ignore stop */ From 3a51c6cbbf0e3727695ebe115e6631b9270ea698 Mon Sep 17 00:00:00 2001 From: Simone Sanfratello Date: Wed, 19 Feb 2025 13:23:52 +0100 Subject: [PATCH 25/25] add params to hooks --- README.md | 4 ++-- examples/reconnection/proxy/index.js | 5 +--- index.js | 36 ++++++++++++++++++---------- src/options.js | 7 +++++- test/options.js | 9 +++++-- test/websocket.js | 8 +++---- test/ws-reconnect.js | 4 ++-- 7 files changed, 45 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 9720f17..0e52d6c 100644 --- a/README.md +++ b/README.md @@ -251,8 +251,8 @@ See the example in [examples/reconnection](examples/reconnection). On websocket events, the following hooks are available, note **the hooks are all synchronous**. -- `onIncomingMessage`: A hook function that is called when the request is received from the client `onIncomingMessage({ data, binary })` (default: `undefined`). -- `onOutgoingMessage`: A hook function that is called when the response is received from the target `onOutgoingMessage({ data, binary })` (default: `undefined`). +- `onIncomingMessage`: A hook function that is called when the request is received from the client `onIncomingMessage(source, target, { data, binary })` (default: `undefined`). +- `onOutgoingMessage`: A hook function that is called when the response is received from the target `onOutgoingMessage(source, target, { data, binary })` (default: `undefined`). - `onConnect`: A hook function that is called when the connection is established `onConnect(source, target)` (default: `undefined`). - `onDisconnect`: A hook function that is called when the connection is closed `onDisconnect(source)` (default: `undefined`). - `onReconnect`: A hook function that is called when the connection is reconnected `onReconnect(source, target)` (default: `undefined`). The function is called if reconnection feature is enabled. diff --git a/examples/reconnection/proxy/index.js b/examples/reconnection/proxy/index.js index 6a49def..770ec67 100644 --- a/examples/reconnection/proxy/index.js +++ b/examples/reconnection/proxy/index.js @@ -40,14 +40,11 @@ async function main () { // clean backup from the last ping backup = backup.filter(message => message.timestamp > lastPong) }, - onIncomingMessage: (message) => { + onIncomingMessage: (source, target, message) => { const m = message.data.toString() console.log('onIncomingMessage backup', m) backup.push({ message: m, timestamp: Date.now() }) }, - // onOutgoingMessage: (message) => { - // console.log('onOutgoingMessage', message.data.toString()) - // }, onDisconnect: () => { console.log('onDisconnect') backup.length = 0 diff --git a/index.js b/index.js index 76abd9c..4f0e5af 100644 --- a/index.js +++ b/index.js @@ -100,7 +100,7 @@ function proxyWebSockets (logger, source, target, hooks) { source.on('message', (data, binary) => { if (hooks.onIncomingMessage) { try { - hooks.onIncomingMessage({ data, binary }) + hooks.onIncomingMessage(source, target, { data, binary }) } catch (err) { logger.error({ err }, 'proxy ws error from onIncomingMessage hook') } @@ -121,7 +121,7 @@ function proxyWebSockets (logger, source, target, hooks) { target.on('message', (data, binary) => { if (hooks.onOutgoingMessage) { try { - hooks.onOutgoingMessage({ data, binary }) + hooks.onOutgoingMessage(source, target, { data, binary }) } catch (err) { logger.error({ err }, 'proxy ws error from onOutgoingMessage hook') } @@ -167,10 +167,20 @@ async function reconnect (logger, source, reconnectOptions, hooks, targetParams) attempts++ target = undefined } - } while (!target && attempts < reconnectOptions.maxReconnectionRetries) + // stop if the source connection is closed during the reconnection + } while (source.isAlive && !target && attempts < reconnectOptions.maxReconnectionRetries) + + /* c8 ignore start */ + if (!source.isAlive) { + reconnectOptions.logs && logger.info({ target: targetParams.url, attempts }, 'proxy ws abort reconnect due to source is closed') + source.close() + return + } + /* c8 ignore stop */ if (!target) { logger.error({ target: targetParams.url, attempts }, 'proxy ws failed to reconnect! No more retries') + source.close() return } @@ -184,14 +194,6 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks target.pingTimer = undefined closeWebSocket(target, code, reason) - if (hooks.onDisconnect) { - try { - hooks.onDisconnect(source) - } catch (err) { - options.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onDisconnect hook') - } - } - // reconnect target as long as the source connection is active if (source.isAlive && (target.broken || options.reconnectOnClose)) { // clean up the target and related source listeners @@ -202,6 +204,14 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks return } + if (hooks.onDisconnect) { + try { + hooks.onDisconnect(source) + } catch (err) { + options.logs && logger.error({ target: targetParams.url, err }, 'proxy ws error from onDisconnect hook') + } + } + options.logs && logger.info({ msg: 'proxy ws close link' }) closeWebSocket(source, code, reason) closeWebSocket(target, code, reason) @@ -221,7 +231,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks source.isAlive = true if (hooks.onIncomingMessage) { try { - hooks.onIncomingMessage({ data, binary }) + hooks.onIncomingMessage(source, target, { data, binary }) } catch (err) { logger.error({ target: targetParams.url, err }, 'proxy ws error from onIncomingMessage hook') } @@ -271,7 +281,7 @@ function proxyWebSocketsWithReconnection (logger, source, target, options, hooks target.isAlive = true if (hooks.onOutgoingMessage) { try { - hooks.onOutgoingMessage({ data, binary }) + hooks.onOutgoingMessage(source, target, { data, binary }) } catch (err) { logger.error({ target: targetParams.url, err }, 'proxy ws error from onOutgoingMessage hook') } diff --git a/src/options.js b/src/options.js index 6096ded..658bdc5 100644 --- a/src/options.js +++ b/src/options.js @@ -66,11 +66,16 @@ function validateOptions (options) { if (wsHooks.onOutgoingMessage !== undefined && typeof wsHooks.onOutgoingMessage !== 'function') { throw new Error('wsHooks.onOutgoingMessage must be a function') } + + if (wsHooks.onPong !== undefined && typeof wsHooks.onPong !== 'function') { + throw new Error('wsHooks.onPong must be a function') + } } else { options.wsHooks = { onReconnect: undefined, onIncomingMessage: undefined, - onOutgoingMessage: undefined + onOutgoingMessage: undefined, + onPong: undefined, } } diff --git a/test/options.js b/test/options.js index 115d71d..fea5bf5 100644 --- a/test/options.js +++ b/test/options.js @@ -52,6 +52,9 @@ test('validateOptions', (t) => { assert.throws(() => validateOptions({ ...requiredOptions, wsHooks: { onOutgoingMessage: '1' } }), /wsHooks.onOutgoingMessage must be a function/) assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsHooks: { onOutgoingMessage: () => { } } })) + assert.throws(() => validateOptions({ ...requiredOptions, wsHooks: { onPong: '1' } }), /wsHooks.onPong must be a function/) + assert.doesNotThrow(() => validateOptions({ ...requiredOptions, wsHooks: { onPong: () => { } } })) + // set all values assert.doesNotThrow(() => validateOptions({ ...requiredOptions, @@ -67,7 +70,8 @@ test('validateOptions', (t) => { wsHooks: { onReconnect: () => { }, onIncomingMessage: () => { }, - onOutgoingMessage: () => { } + onOutgoingMessage: () => { }, + onPong: () => { } } })) @@ -86,7 +90,8 @@ test('validateOptions', (t) => { wsHooks: { onReconnect: undefined, onIncomingMessage: undefined, - onOutgoingMessage: undefined + onOutgoingMessage: undefined, + onPong: undefined, } }) }) diff --git a/test/websocket.js b/test/websocket.js index b2a7c18..7c10e97 100644 --- a/test/websocket.js +++ b/test/websocket.js @@ -716,12 +716,12 @@ test('multiple websocket upstreams with distinct server options', async (t) => { test('should call onIncomingMessage and onOutgoingMessage hooks', async (t) => { const request = 'query () { ... }' const response = 'data ...' - const onIncomingMessage = ({ data, binary }) => { + const onIncomingMessage = (source, target, { data, binary }) => { assert.strictEqual(data.toString(), request) assert.strictEqual(binary, false) logger.info('onIncomingMessage called') } - const onOutgoingMessage = ({ data, binary }) => { + const onOutgoingMessage = (source, target, { data, binary }) => { assert.strictEqual(data.toString(), response) assert.strictEqual(binary, false) logger.info('onOutgoingMessage called') @@ -744,12 +744,12 @@ test('should call onIncomingMessage and onOutgoingMessage hooks', async (t) => { test('should handle throwing an error in onIncomingMessage and onOutgoingMessage hooks', async (t) => { const request = 'query () { ... }' const response = 'data ...' - const onIncomingMessage = ({ data, binary }) => { + const onIncomingMessage = (source, target, { data, binary }) => { assert.strictEqual(data.toString(), request) assert.strictEqual(binary, false) throw new Error('onIncomingMessage error') } - const onOutgoingMessage = ({ data, binary }) => { + const onOutgoingMessage = (source, target, { data, binary }) => { assert.strictEqual(data.toString(), response) assert.strictEqual(binary, false) throw new Error('onOutgoingMessage error') diff --git a/test/ws-reconnect.js b/test/ws-reconnect.js index c86a611..56f34b7 100644 --- a/test/ws-reconnect.js +++ b/test/ws-reconnect.js @@ -203,12 +203,12 @@ test('should handle throwing an error in onReconnect hook', async (t) => { test('should call onIncomingMessage and onOutgoingMessage hooks, with reconnection', async (t) => { const request = 'query () { ... }' const response = 'data ...' - const onIncomingMessage = ({ data, binary }) => { + const onIncomingMessage = (source, target, { data, binary }) => { assert.strictEqual(data.toString(), request) assert.strictEqual(binary, false) logger.info('onIncomingMessage called') } - const onOutgoingMessage = ({ data, binary }) => { + const onOutgoingMessage = (source, target, { data, binary }) => { assert.strictEqual(data.toString(), response) assert.strictEqual(binary, false) logger.info('onOutgoingMessage called')