Skip to content

Commit 0f7b501

Browse files
authored
Remove fastify-websocket, proxy ws events directly (#122)
* Drop fastify-websocket * Add socket.io tests * Disconnect websockets when server is closing * Remove rejectUnauthorized default value * Add plugin metadata * Actually test socket.io proxy * Fix ws server closing * Test ws clients close event * Add missing tearDown
1 parent bd82f1a commit 0f7b501

File tree

7 files changed

+179
-45
lines changed

7 files changed

+179
-45
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ This module has _partial_ support for forwarding websockets by passing a
152152
A few things are missing:
153153

154154
1. forwarding headers as well as `rewriteHeaders`
155-
2. support for paths, `prefix` and `rewritePrefix`
156-
3. request id logging
155+
2. request id logging
156+
3. support `ignoreTrailingSlash`
157157

158158
Pull requests are welcome to finish this feature.
159159

index.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import {
99
FastifyReplyFromOptions
1010
} from "fastify-reply-from"
1111

12+
import {
13+
ClientOptions,
14+
ServerOptions
15+
} from "ws"
16+
1217
export interface FastifyHttpProxyOptions extends FastifyReplyFromOptions {
1318
upstream: string;
1419
prefix?: string;
@@ -18,6 +23,9 @@ export interface FastifyHttpProxyOptions extends FastifyReplyFromOptions {
1823
beforeHandler?: preHandlerHookHandler;
1924
config?: Object;
2025
replyOptions?: Object;
26+
websocket?: boolean
27+
wsClientOptions?: ClientOptions
28+
wsServerOptions?: ServerOptions
2129
}
2230

2331
declare const fastifyHttpProxy: FastifyPlugin<FastifyHttpProxyOptions>;

index.js

Lines changed: 107 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,104 @@
11
'use strict'
22

33
const From = require('fastify-reply-from')
4-
const WebSocketPlugin = require('fastify-websocket')
54
const WebSocket = require('ws')
6-
const { pipeline } = require('stream')
7-
const nonWsMethods = ['DELETE', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS']
85

9-
module.exports = async function (fastify, opts) {
6+
const httpMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS']
7+
8+
function liftErrorCode (code) {
9+
if (typeof code !== 'number') {
10+
// Sometimes "close" event emits with a non-numeric value
11+
return 1011
12+
} else if (code === 1004 || code === 1005 || code === 1006) {
13+
// ws module forbid those error codes usage, lift to "application level" (4xxx)
14+
return 4000 + (code % 1000)
15+
} else {
16+
return code
17+
}
18+
}
19+
20+
function closeWebSocket (socket, code, reason) {
21+
if (socket.readyState === WebSocket.OPEN) {
22+
socket.close(liftErrorCode(code), reason)
23+
}
24+
}
25+
26+
function waitConnection (socket, write) {
27+
if (socket.readyState === WebSocket.CONNECTING) {
28+
socket.once('open', write)
29+
} else {
30+
write()
31+
}
32+
}
33+
34+
function proxyWebSockets (source, target) {
35+
function close (code, reason) {
36+
closeWebSocket(source, code, reason)
37+
closeWebSocket(target, code, reason)
38+
}
39+
40+
source.on('message', data => waitConnection(target, () => target.send(data)))
41+
source.on('ping', data => waitConnection(target, () => target.ping(data)))
42+
source.on('pong', data => waitConnection(target, () => target.pong(data)))
43+
source.on('close', close)
44+
source.on('error', error => close(1011, error.message))
45+
source.on('unexpected-response', () => close(1011, 'unexpected response'))
46+
47+
// source WebSocket is already connected because it is created by ws server
48+
target.on('message', data => source.send(data))
49+
target.on('ping', data => source.ping(data))
50+
target.on('pong', data => source.pong(data))
51+
target.on('close', close)
52+
target.on('error', error => close(1011, error.message))
53+
target.on('unexpected-response', () => close(1011, 'unexpected response'))
54+
}
55+
56+
function createWebSocketUrl (options, request) {
57+
const source = new URL(request.url, 'http://127.0.0.1')
58+
59+
const target = new URL(
60+
options.rewritePrefix || options.prefix || source.pathname,
61+
options.upstream
62+
)
63+
64+
target.search = source.search
65+
66+
return target
67+
}
68+
69+
function setupWebSocketProxy (fastify, options) {
70+
const server = new WebSocket.Server({
71+
path: options.prefix,
72+
server: fastify.server,
73+
...options.wsServerOptions
74+
})
75+
76+
fastify.addHook('onClose', (instance, done) => server.close(done))
77+
78+
// To be able to close the HTTP server,
79+
// all WebSocket clients need to be disconnected.
80+
// Fastify is missing a pre-close event, or the ability to
81+
// add a hook before the server.close call. We need to resort
82+
// to monkeypatching for now.
83+
const oldClose = fastify.server.close
84+
fastify.server.close = function (done) {
85+
for (const client of server.clients) {
86+
client.close()
87+
}
88+
oldClose.call(this, done)
89+
}
90+
91+
server.on('connection', (source, request) => {
92+
const url = createWebSocketUrl(options, request)
93+
94+
const target = new WebSocket(url, options.wsClientOptions)
95+
96+
fastify.log.debug({ url: url.href }, 'proxy websocket')
97+
proxyWebSockets(source, target)
98+
})
99+
}
100+
101+
async function httpProxy (fastify, opts) {
10102
if (!opts.upstream) {
11103
throw new Error('upstream must be specified')
12104
}
@@ -46,33 +138,16 @@ module.exports = async function (fastify, opts) {
46138
done(null, payload)
47139
}
48140

49-
if (opts.websocket) {
50-
fastify.register(WebSocketPlugin, opts.websocket)
51-
}
52-
53-
fastify.get('/', {
54-
preHandler,
55-
config: opts.config || {},
56-
handler,
57-
wsHandler
58-
})
59-
fastify.get('/*', {
60-
preHandler,
61-
config: opts.config || {},
62-
handler,
63-
wsHandler
64-
})
65-
66141
fastify.route({
67142
url: '/',
68-
method: nonWsMethods,
143+
method: httpMethods,
69144
preHandler,
70145
config: opts.config || {},
71146
handler
72147
})
73148
fastify.route({
74149
url: '/*',
75-
method: nonWsMethods,
150+
method: httpMethods,
76151
preHandler,
77152
config: opts.config || {},
78153
handler
@@ -84,21 +159,14 @@ module.exports = async function (fastify, opts) {
84159
reply.from(dest || '/', replyOpts)
85160
}
86161

87-
function wsHandler (conn, req) {
88-
// TODO support paths and querystrings
89-
// TODO support rewriteHeader
90-
// TODO support rewritePrefix
91-
const ws = new WebSocket(opts.upstream)
92-
const stream = WebSocket.createWebSocketStream(ws)
93-
94-
// TODO fastify-websocket should create a logger for each connection
95-
fastify.log.info('starting websocket tunnel')
96-
pipeline(conn, stream, conn, function (err) {
97-
if (err) {
98-
fastify.log.info({ err }, 'websocket tunnel terminated with error')
99-
return
100-
}
101-
fastify.log.info('websocket tunnel terminated')
102-
})
162+
if (opts.websocket) {
163+
setupWebSocketProxy(fastify, opts)
103164
}
104165
}
166+
167+
httpProxy[Symbol.for('plugin-meta')] = {
168+
fastify: '^3.0.0',
169+
name: 'fastify-http-proxy'
170+
}
171+
172+
module.exports = httpProxy

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"homepage": "https://github.com/fastify/fastify-http-proxy#readme",
2727
"devDependencies": {
2828
"@types/node": "^14.0.27",
29+
"@types/ws": "^7.4.0",
2930
"@typescript-eslint/parser": "^4.0.0",
3031
"eslint-plugin-typescript": "^0.14.0",
3132
"express": "^4.16.4",
@@ -36,18 +37,18 @@
3637
"http-errors": "^1.8.0",
3738
"http-proxy": "^1.17.0",
3839
"make-promises-safe": "^5.0.0",
39-
"pre-commit": "^1.2.2",
4040
"simple-get": "^4.0.0",
4141
"snazzy": "^9.0.0",
42+
"socket.io": "^3.0.4",
43+
"socket.io-client": "^3.0.4",
4244
"standard": "^16.0.3",
4345
"tap": "^14.10.8",
4446
"tsd": "^0.14.0",
4547
"typescript": "^4.0.2"
4648
},
4749
"dependencies": {
4850
"fastify-reply-from": "^3.1.3",
49-
"fastify-websocket": "^2.0.7",
50-
"ws": "^7.3.1"
51+
"ws": "^7.4.1"
5152
},
5253
"tsd": {
5354
"directory": "test"

test/socket.io.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use strict'
2+
3+
const { test } = require('tap')
4+
const Fastify = require('fastify')
5+
const proxy = require('../')
6+
const ioServer = require('socket.io')
7+
const ioClient = require('socket.io-client')
8+
const { createServer } = require('http')
9+
const { promisify } = require('util')
10+
const { once } = require('events')
11+
12+
test('proxy socket.io', async t => {
13+
t.plan(2)
14+
15+
const srvUpstream = createServer()
16+
t.tearDown(srvUpstream.close.bind(srvUpstream))
17+
18+
const srvSocket = new ioServer.Server(srvUpstream)
19+
t.tearDown(srvSocket.close.bind(srvSocket))
20+
21+
await promisify(srvUpstream.listen.bind(srvUpstream))(0)
22+
23+
const srvProxy = Fastify()
24+
t.tearDown(srvProxy.close.bind(srvProxy))
25+
26+
srvProxy.register(proxy, {
27+
upstream: `http://127.0.0.1:${srvUpstream.address().port}`,
28+
websocket: true
29+
})
30+
31+
await srvProxy.listen(0)
32+
33+
srvSocket.on('connection', socket => {
34+
socket.on('hello', data => {
35+
t.is(data, 'world')
36+
socket.emit('hi', 'socket')
37+
})
38+
})
39+
40+
const cliSocket = ioClient(`http://127.0.0.1:${srvProxy.server.address().port}`)
41+
t.tearDown(cliSocket.close.bind(cliSocket))
42+
43+
cliSocket.emit('hello', 'world')
44+
45+
const out = await once(cliSocket, 'hi')
46+
t.is(out[0], 'socket')
47+
48+
await Promise.all([
49+
once(cliSocket, 'disconnect'),
50+
srvProxy.close()
51+
])
52+
})

test/test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,9 @@ async function run () {
180180
t.deepEqual(reply.context.config, {
181181
foo: 'bar',
182182
url: '/*',
183-
// GET is not there because of the nonWsMethods.
184183
method: [
185184
'DELETE',
185+
'GET',
186186
'HEAD',
187187
'PATCH',
188188
'POST',

test/websocket.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,9 @@ test('basic websocket proxy', async (t) => {
4646
const [buf] = await once(stream, 'data')
4747

4848
t.is(buf.toString(), 'hello')
49+
50+
await Promise.all([
51+
once(ws, 'close'),
52+
server.close()
53+
])
4954
})

0 commit comments

Comments
 (0)