From 7b3ab7378b75f3d2c121ed4d99febc29f92f0eb9 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 4 Apr 2026 17:59:04 +0000 Subject: [PATCH] feat(test): add end-to-end WebSocket proxy test coverage --- changelog.md | 6 + package.json | 4 +- pnpm-lock.yaml | 22 +- test/test.websocket-e2e.ts | 418 +++++++++++++++++++++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- 5 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 test/test.websocket-e2e.ts diff --git a/changelog.md b/changelog.md index 587c1db..1c8a6f1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## 2026-04-04 - 27.3.0 - feat(test) +add end-to-end WebSocket proxy test coverage + +- add comprehensive WebSocket e2e tests for upgrade handling, bidirectional messaging, header forwarding, close propagation, and large payloads +- add ws and @types/ws as development dependencies to support the new test suite + ## 2026-04-04 - 27.2.0 - feat(metrics) add frontend and backend protocol distribution metrics diff --git a/package.json b/package.json index f36b179..b6f1138 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,10 @@ "@git.zone/tstest": "^3.6.0", "@push.rocks/smartserve": "^2.0.3", "@types/node": "^25.5.0", + "@types/ws": "^8.18.1", "typescript": "^6.0.2", - "why-is-node-running": "^3.2.2" + "why-is-node-running": "^3.2.2", + "ws": "^8.20.0" }, "dependencies": { "@push.rocks/smartcrypto": "^2.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d568968..c8cff1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,12 +45,18 @@ importers: '@types/node': specifier: ^25.5.0 version: 25.5.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 typescript: specifier: ^6.0.2 version: 6.0.2 why-is-node-running: specifier: ^3.2.2 version: 3.2.2 + ws: + specifier: ^8.20.0 + version: 8.20.0 packages: @@ -3304,18 +3310,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -5296,7 +5290,7 @@ snapshots: '@push.rocks/smartenv': 6.0.0 '@push.rocks/smartlog': 3.2.1 '@push.rocks/smartpath': 6.0.0 - ws: 8.19.0 + ws: 8.20.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -8033,8 +8027,6 @@ snapshots: wrappy@1.0.2: {} - ws@8.19.0: {} - ws@8.20.0: {} xml-parse-from-string@1.0.1: {} diff --git a/test/test.websocket-e2e.ts b/test/test.websocket-e2e.ts new file mode 100644 index 0000000..434dc86 --- /dev/null +++ b/test/test.websocket-e2e.ts @@ -0,0 +1,418 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { SmartProxy } from '../ts/index.js'; +import * as http from 'http'; +import WebSocket, { WebSocketServer } from 'ws'; +import { findFreePorts, assertPortsFree } from './helpers/port-allocator.js'; + +/** + * Helper: create a WebSocket client that connects through the proxy. + * Registers the message handler BEFORE awaiting open to avoid race conditions. + */ +function connectWs( + url: string, + headers: Record = {}, + opts: WebSocket.ClientOptions = {}, +): { ws: WebSocket; messages: string[]; opened: Promise } { + const messages: string[] = []; + const ws = new WebSocket(url, { headers, ...opts }); + + // Register message handler immediately — before open fires + ws.on('message', (data) => { + messages.push(data.toString()); + }); + + const opened = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('WebSocket open timeout')), 5000); + ws.on('open', () => { clearTimeout(timeout); resolve(); }); + ws.on('error', (err) => { clearTimeout(timeout); reject(err); }); + }); + + return { ws, messages, opened }; +} + +/** Wait until `predicate` returns true, with a hard timeout. */ +function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const deadline = setTimeout(() => reject(new Error('waitFor timeout')), timeoutMs); + const check = () => { + if (predicate()) { clearTimeout(deadline); resolve(); } + else setTimeout(check, 30); + }; + check(); + }); +} + +/** Graceful close helper */ +function closeWs(ws: WebSocket): Promise { + return new Promise((resolve) => { + if (ws.readyState === WebSocket.CLOSED) return resolve(); + ws.on('close', () => resolve()); + ws.close(); + setTimeout(resolve, 2000); // fallback + }); +} + +// ─── Test 1: Basic WebSocket upgrade and bidirectional messaging ─── +tap.test('should proxy WebSocket connections with bidirectional messaging', async () => { + const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2); + + // Backend: echoes messages with prefix, sends greeting on connect + const backendServer = http.createServer(); + const wss = new WebSocketServer({ server: backendServer }); + const backendMessages: string[] = []; + + wss.on('connection', (ws) => { + ws.on('message', (data) => { + const msg = data.toString(); + backendMessages.push(msg); + ws.send(`echo: ${msg}`); + }); + ws.send('hello from backend'); + }); + + await new Promise((resolve) => { + backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve()); + }); + + const proxy = new SmartProxy({ + routes: [{ + name: 'ws-test-route', + match: { ports: PROXY_PORT }, + action: { + type: 'forward', + targets: [{ host: '127.0.0.1', port: BACKEND_PORT }], + websocket: { enabled: true }, + }, + }], + }); + await proxy.start(); + + // Connect client — message handler registered before open + const { ws, messages, opened } = connectWs( + `ws://127.0.0.1:${PROXY_PORT}/`, + { Host: 'test.local' }, + ); + await opened; + + // Wait for the backend greeting + await waitFor(() => messages.length >= 1); + expect(messages[0]).toEqual('hello from backend'); + + // Send 3 messages, expect 3 echoes + ws.send('ping 1'); + ws.send('ping 2'); + ws.send('ping 3'); + + await waitFor(() => messages.length >= 4); + + expect(messages).toContain('echo: ping 1'); + expect(messages).toContain('echo: ping 2'); + expect(messages).toContain('echo: ping 3'); + expect(backendMessages).toInclude('ping 1'); + expect(backendMessages).toInclude('ping 2'); + expect(backendMessages).toInclude('ping 3'); + + await closeWs(ws); + await proxy.stop(); + await new Promise((resolve) => backendServer.close(() => resolve())); + await new Promise((r) => setTimeout(r, 500)); + await assertPortsFree([PROXY_PORT, BACKEND_PORT]); +}); + +// ─── Test 2: Multiple concurrent WebSocket connections ─── +tap.test('should handle multiple concurrent WebSocket connections', async () => { + const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2); + + const backendServer = http.createServer(); + const wss = new WebSocketServer({ server: backendServer }); + + let connectionCount = 0; + wss.on('connection', (ws) => { + const id = ++connectionCount; + ws.on('message', (data) => { + ws.send(`conn${id}: ${data.toString()}`); + }); + }); + + await new Promise((resolve) => { + backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve()); + }); + + const proxy = new SmartProxy({ + routes: [{ + name: 'ws-multi-route', + match: { ports: PROXY_PORT }, + action: { + type: 'forward', + targets: [{ host: '127.0.0.1', port: BACKEND_PORT }], + websocket: { enabled: true }, + }, + }], + }); + await proxy.start(); + + const NUM_CLIENTS = 5; + const clients: { ws: WebSocket; messages: string[] }[] = []; + + for (let i = 0; i < NUM_CLIENTS; i++) { + const c = connectWs( + `ws://127.0.0.1:${PROXY_PORT}/`, + { Host: 'test.local' }, + ); + await c.opened; + clients.push(c); + } + + // Each client sends a unique message + for (let i = 0; i < NUM_CLIENTS; i++) { + clients[i].ws.send(`hello from client ${i}`); + } + + // Wait for all replies + await waitFor(() => clients.every((c) => c.messages.length >= 1)); + + for (let i = 0; i < NUM_CLIENTS; i++) { + expect(clients[i].messages.length).toBeGreaterThanOrEqual(1); + expect(clients[i].messages[0]).toInclude(`hello from client ${i}`); + } + expect(connectionCount).toEqual(NUM_CLIENTS); + + for (const c of clients) await closeWs(c.ws); + await proxy.stop(); + await new Promise((resolve) => backendServer.close(() => resolve())); + await new Promise((r) => setTimeout(r, 500)); + await assertPortsFree([PROXY_PORT, BACKEND_PORT]); +}); + +// ─── Test 3: WebSocket with binary data ─── +tap.test('should proxy binary WebSocket frames', async () => { + const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2); + + const backendServer = http.createServer(); + const wss = new WebSocketServer({ server: backendServer }); + + wss.on('connection', (ws) => { + ws.on('message', (data) => { + ws.send(data, { binary: true }); + }); + }); + + await new Promise((resolve) => { + backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve()); + }); + + const proxy = new SmartProxy({ + routes: [{ + name: 'ws-binary-route', + match: { ports: PROXY_PORT }, + action: { + type: 'forward', + targets: [{ host: '127.0.0.1', port: BACKEND_PORT }], + websocket: { enabled: true }, + }, + }], + }); + await proxy.start(); + + const receivedBuffers: Buffer[] = []; + const ws = new WebSocket(`ws://127.0.0.1:${PROXY_PORT}/`, { + headers: { Host: 'test.local' }, + }); + ws.on('message', (data) => { + receivedBuffers.push(Buffer.from(data as ArrayBuffer)); + }); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('timeout')), 5000); + ws.on('open', () => { clearTimeout(timeout); resolve(); }); + ws.on('error', (err) => { clearTimeout(timeout); reject(err); }); + }); + + // Send a 256-byte buffer with known content + const sentBuffer = Buffer.alloc(256); + for (let i = 0; i < 256; i++) sentBuffer[i] = i; + ws.send(sentBuffer); + + await waitFor(() => receivedBuffers.length >= 1); + + expect(receivedBuffers[0].length).toEqual(256); + expect(Buffer.compare(receivedBuffers[0], sentBuffer)).toEqual(0); + + await closeWs(ws); + await proxy.stop(); + await new Promise((resolve) => backendServer.close(() => resolve())); + await new Promise((r) => setTimeout(r, 500)); + await assertPortsFree([PROXY_PORT, BACKEND_PORT]); +}); + +// ─── Test 4: WebSocket path and query string preserved ─── +tap.test('should preserve path and query string through proxy', async () => { + const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2); + + const backendServer = http.createServer(); + const wss = new WebSocketServer({ server: backendServer }); + + let receivedUrl = ''; + wss.on('connection', (ws, req) => { + receivedUrl = req.url || ''; + ws.send(`url: ${receivedUrl}`); + }); + + await new Promise((resolve) => { + backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve()); + }); + + const proxy = new SmartProxy({ + routes: [{ + name: 'ws-path-route', + match: { ports: PROXY_PORT }, + action: { + type: 'forward', + targets: [{ host: '127.0.0.1', port: BACKEND_PORT }], + websocket: { enabled: true }, + }, + }], + }); + await proxy.start(); + + const { ws, messages, opened } = connectWs( + `ws://127.0.0.1:${PROXY_PORT}/chat/room1?token=abc123`, + { Host: 'test.local' }, + ); + await opened; + + await waitFor(() => messages.length >= 1); + + expect(receivedUrl).toEqual('/chat/room1?token=abc123'); + expect(messages[0]).toEqual('url: /chat/room1?token=abc123'); + + await closeWs(ws); + await proxy.stop(); + await new Promise((resolve) => backendServer.close(() => resolve())); + await new Promise((r) => setTimeout(r, 500)); + await assertPortsFree([PROXY_PORT, BACKEND_PORT]); +}); + +// ─── Test 5: Clean close propagation ─── +tap.test('should handle clean WebSocket close from client', async () => { + const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2); + + const backendServer = http.createServer(); + const wss = new WebSocketServer({ server: backendServer }); + + let backendGotClose = false; + let backendCloseCode = 0; + wss.on('connection', (ws) => { + ws.on('close', (code) => { + backendGotClose = true; + backendCloseCode = code; + }); + ws.on('message', (data) => { + ws.send(data); + }); + }); + + await new Promise((resolve) => { + backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve()); + }); + + const proxy = new SmartProxy({ + routes: [{ + name: 'ws-close-route', + match: { ports: PROXY_PORT }, + action: { + type: 'forward', + targets: [{ host: '127.0.0.1', port: BACKEND_PORT }], + websocket: { enabled: true }, + }, + }], + }); + await proxy.start(); + + const { ws, messages, opened } = connectWs( + `ws://127.0.0.1:${PROXY_PORT}/`, + { Host: 'test.local' }, + ); + await opened; + + // Confirm connection works with a round-trip + ws.send('test'); + await waitFor(() => messages.length >= 1); + + // Close with code 1000 + let clientCloseCode = 0; + const closed = new Promise((resolve) => { + ws.on('close', (code) => { + clientCloseCode = code; + resolve(); + }); + setTimeout(resolve, 3000); + }); + ws.close(1000, 'done'); + await closed; + + // Wait for backend to register + await waitFor(() => backendGotClose, 3000); + + expect(backendGotClose).toBeTrue(); + expect(clientCloseCode).toEqual(1000); + + await proxy.stop(); + await new Promise((resolve) => backendServer.close(() => resolve())); + await new Promise((r) => setTimeout(r, 500)); + await assertPortsFree([PROXY_PORT, BACKEND_PORT]); +}); + +// ─── Test 6: Large messages ─── +tap.test('should handle large WebSocket messages', async () => { + const [PROXY_PORT, BACKEND_PORT] = await findFreePorts(2); + + const backendServer = http.createServer(); + const wss = new WebSocketServer({ server: backendServer, maxPayload: 5 * 1024 * 1024 }); + + wss.on('connection', (ws) => { + ws.on('message', (data) => { + const buf = Buffer.from(data as ArrayBuffer); + ws.send(`received ${buf.length} bytes`); + }); + }); + + await new Promise((resolve) => { + backendServer.listen(BACKEND_PORT, '127.0.0.1', () => resolve()); + }); + + const proxy = new SmartProxy({ + routes: [{ + name: 'ws-large-route', + match: { ports: PROXY_PORT }, + action: { + type: 'forward', + targets: [{ host: '127.0.0.1', port: BACKEND_PORT }], + websocket: { enabled: true }, + }, + }], + }); + await proxy.start(); + + const { ws, messages, opened } = connectWs( + `ws://127.0.0.1:${PROXY_PORT}/`, + { Host: 'test.local' }, + { maxPayload: 5 * 1024 * 1024 }, + ); + await opened; + + // Send a 1MB message + const largePayload = Buffer.alloc(1024 * 1024, 0x42); + ws.send(largePayload); + + await waitFor(() => messages.length >= 1); + expect(messages[0]).toEqual(`received ${1024 * 1024} bytes`); + + await closeWs(ws); + await proxy.stop(); + await new Promise((resolve) => backendServer.close(() => resolve())); + await new Promise((r) => setTimeout(r, 500)); + await assertPortsFree([PROXY_PORT, BACKEND_PORT]); +}); + +export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index a3c3ac4..d8cdd98 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '27.2.0', + version: '27.3.0', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' }