diff --git a/README.md b/README.md index 48e68542..d65cddb7 100644 --- a/README.md +++ b/README.md @@ -487,6 +487,8 @@ The following options are provided by the underlying [httpxy](https://github.com ## WebSocket +See [recipes/websocket.md](recipes/websocket.md) for more examples. + ```javascript // verbose api createProxyMiddleware({ pathFilter: '/', target: 'http://echo.websocket.org', ws: true }); diff --git a/recipes/websocket.md b/recipes/websocket.md index 6982ed4e..6a24bdaf 100644 --- a/recipes/websocket.md +++ b/recipes/websocket.md @@ -1,50 +1,84 @@ # WebSocket -This example will create a proxy middleware with websocket support. +Examples to use `http-proxy-middleware` with WebSocket support. + +- [WebSocket - `ws:true` flag (automatic upgrade subscription)](#websocket---wstrue-flag-automatic-upgrade-subscription) +- [WebSocket - Manual server upgrade subscription](#websocket---manual-server-upgrade-subscription) +- [Multiple WebSocket targets](#multiple-websocket-targets) +- [WebSocket - Path Rewrite](#websocket---path-rewrite) + +## WebSocket - `ws:true` flag (automatic upgrade subscription) + +⚠️ NOTE: Using `ws: true` requires an an initial regular HTTP request, so HPM can subscribe to server upgrade event internally. + +💡 Use `server.on('upgrade', proxy.upgrade)` without the need of an initial HTTP request. ```javascript import { createProxyMiddleware } from 'http-proxy-middleware'; const socketProxy = createProxyMiddleware({ target: 'http://localhost:3000', - pathFilter: '/socket', ws: true, }); ``` -## WebSocket - Path Rewrite +## WebSocket - Manual server upgrade subscription -This example will create a proxy middleware with websocket support and pathRewrite. +Manually subscribe to server's upgrade event. ```javascript import { createProxyMiddleware } from 'http-proxy-middleware'; -const options = { +const socketProxy = createProxyMiddleware({ target: 'http://localhost:3000', - ws: true, pathFilter: '/socket', - pathRewrite: { - '^/socket': '', - }, -}; + ws: true, +}); -const socketProxy = createProxyMiddleware(options); +server.on('upgrade', socketProxy.upgrade); // <-- subscribe to http 'upgrade' ``` -## WebSocket - Server update subscription +## Multiple WebSocket targets + +Mount each websocket proxy with different target on its own route. + +```javascript +import express from 'express'; +import { createProxyMiddleware } from 'http-proxy-middleware'; + +const app = express(); + +const wsProxyA = createProxyMiddleware({ + target: 'http://localhost:4001', + pathFilter: '/ws-path-a', + ws: true, +}); -This example will create a proxy middleware with websocket support. +const wsProxyB = createProxyMiddleware({ + target: 'http://localhost:4002', + pathFilter: '/ws-path-b', + ws: true, +}); -Subscribe to server's upgrade event. +app.use('/ws-path-a', wsProxyA); +app.use('/ws-path-b', wsProxyB); +``` + +## WebSocket - Path Rewrite + +This example will create a proxy middleware with websocket support and pathRewrite. ```javascript import { createProxyMiddleware } from 'http-proxy-middleware'; -const socketProxy = createProxyMiddleware({ +const options = { target: 'http://localhost:3000', - pathFilter: '/socket', ws: true, -}); + pathFilter: '/socket', + pathRewrite: { + '^/socket': '', + }, +}; -server.on('upgrade', socketProxy.upgrade); // <-- subscribe to http 'upgrade' +const socketProxy = createProxyMiddleware(options); ``` diff --git a/test/e2e/websocket.spec.ts b/test/e2e/websocket.spec.ts index 5bb14d9e..a14bbf3f 100644 --- a/test/e2e/websocket.spec.ts +++ b/test/e2e/websocket.spec.ts @@ -10,7 +10,7 @@ import { WebSocket } from 'ws'; import type { RequestHandler } from '../../src/types.js'; import { createMockRequest, createMockResponse } from '../test-utils.js'; -import { createApp, createProxyMiddleware } from './test-kit.js'; +import { createApp, createAppWithPath, createProxyMiddleware } from './test-kit.js'; /******************************************************************** * - Not possible to use `supertest` to test WebSockets @@ -123,6 +123,119 @@ describe('E2E WebSocket proxy', () => { await expect(messageReceived).resolves.toBe('foobar'); }); + + it('should forward websocket upgrade to /ws-path without duplicating the mounted path', async () => { + const sockJsPort = await getPort(); + + const sockJsTargetServer = getLocal(); + await sockJsTargetServer.start(); + // performs an initial regular HTTP GET request subscribe to server upgrade event internally. + await sockJsTargetServer.forGet('/ws-path').thenReply(200, 'ok'); + const sockJsRule = await sockJsTargetServer.forAnyWebSocket().thenEcho(); + + const sockJsMiddleware = createProxyMiddleware({ + target: `${sockJsTargetServer.url}`, + ws: true, + }); + + const sockJsServer = createAppWithPath('/ws-path', sockJsMiddleware).listen(sockJsPort); + + await new Promise((resolve, reject) => { + http.get(`http://localhost:${sockJsPort}/ws-path`, resolve).on('error', reject); + }); + + const sockJsClient = new WebSocket(`ws://localhost:${sockJsPort}/ws-path`); + + try { + await new Promise((resolve, reject) => { + sockJsClient.once('open', () => resolve()); + sockJsClient.once('error', reject); + }); + + const seenRequests = await sockJsRule.getSeenRequests(); + expect(seenRequests).toHaveLength(1); + const upstreamPathname = new URL(seenRequests[0].url).pathname; + expect(upstreamPathname).toBe('/ws-path'); + } finally { + await Promise.all([ + closeWebSocketClient(sockJsClient), + closeServer(sockJsServer), + sockJsTargetServer.stop(), + ]); + } + }); + + it('should proxy websocket upgrades for two mounted paths with separate targets', async () => { + const serverPort = await getPort(); + + const targetServerA = getLocal(); + const targetServerB = getLocal(); + await targetServerA.start(); + await targetServerB.start(); + await targetServerA.forGet('/ws-path-a').thenReply(200, 'ok'); + await targetServerB.forGet('/ws-path-b').thenReply(200, 'ok'); + await targetServerA.forAnyWebSocket().thenEcho(); + await targetServerB.forAnyWebSocket().thenEcho(); + + const middlewareA = createProxyMiddleware({ + target: targetServerA.url, + ws: true, + // NOTE: when multiple ws middlewares share one server, scope each with pathFilter + // so only the intended middleware handles a given upgrade request. + pathFilter: '/ws-path-a', + }); + + const middlewareB = createProxyMiddleware({ + target: targetServerB.url, + ws: true, + pathFilter: '/ws-path-b', + }); + + const multiMountServer = createAppWithPath('/ws-path-a', middlewareA) + .use('/ws-path-b', middlewareB) + .listen(serverPort); + + const connectAndEcho = async (path: string, message: string) => { + const socket = new WebSocket(`ws://localhost:${serverPort}${path}`); + + try { + await new Promise((resolve, reject) => { + socket.once('open', () => resolve()); + socket.once('error', reject); + }); + + const echoed = new Promise((resolve) => { + socket.once('message', (data) => resolve(data.toString())); + }); + + socket.send(message); + await expect(echoed).resolves.toBe(message); + } finally { + await closeWebSocketClient(socket); + } + }; + + try { + // NOTE: perform a normal HTTP request per mounted path first so each middleware + // subscribes to the server upgrade event before the websocket connection starts. + await new Promise((resolve, reject) => { + http.get(`http://localhost:${serverPort}/ws-path-a`, resolve).on('error', reject); + }); + + await new Promise((resolve, reject) => { + http.get(`http://localhost:${serverPort}/ws-path-b`, resolve).on('error', reject); + }); + + await connectAndEcho('/ws-path-a', 'from-a'); + await connectAndEcho('/ws-path-b', 'from-b'); + } finally { + await Promise.all([ + closeServer(multiMountServer), + targetServerA.stop(), + targetServerB.stop(), + ]); + } + }); }); describe('option.ws with external server "upgrade"', () => {