Skip to content

Commit ba5f091

Browse files
authored
Merge pull request #27 from minrk/ws-to-http
handle websocket requests to non-websocket targets
2 parents 464fd1a + 565b3af commit ba5f091

File tree

4 files changed

+181
-14
lines changed

4 files changed

+181
-14
lines changed

lib/http-proxy/passes/web-incoming.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ The names of passes are exported as WEB_PASSES from this module.
99

1010
import * as http from "node:http";
1111
import * as https from "node:https";
12-
import { OUTGOING_PASSES } from "./web-outgoing";
12+
import { OUTGOING_PASSES, EditableResponse } from "./web-outgoing";
1313
import * as common from "../common";
1414
import * as followRedirects from "follow-redirects";
1515
import {
@@ -164,7 +164,7 @@ export function stream(req: Request, res: Response, options: NormalizedServerOpt
164164
if (!res.headersSent && !options.selfHandleResponse) {
165165
for (const pass of web_o) {
166166
// note: none of these return anything
167-
pass(req, res, proxyRes, options as NormalizedServerOptions & { target: NormalizeProxyTarget<ProxyTarget> });
167+
pass(req, res as EditableResponse, proxyRes, options as NormalizedServerOptions & { target: NormalizeProxyTarget<ProxyTarget> });
168168
}
169169
}
170170

lib/http-proxy/passes/web-outgoing.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,36 @@ to not return anything.
1111

1212
import type { NormalizedServerOptions, NormalizeProxyTarget, ProxyTarget } from "..";
1313
import * as common from "../common";
14-
import type { Request, Response, ProxyResponse } from "./web-incoming";
14+
import type { Request, ProxyResponse } from "./web-incoming";
1515

1616
const redirectRegex = /^201|30(1|2|7|8)$/;
1717

18+
// interface for subset of Response that's actually used here
19+
// needed for running outgoing passes on MockResponse in ws-incoming
20+
export interface EditableResponse {
21+
statusCode: number;
22+
statusMessage: string;
23+
setHeader(key: string, value: string | string[]): this;
24+
}
25+
1826
// <--
1927

2028
// If is a HTTP 1.0 request, remove chunk headers
2129
export function removeChunked(
22-
req: Request,
23-
_res: Response,
30+
_req: Request,
31+
_res: EditableResponse,
2432
// Response object from the proxy request
2533
proxyRes: ProxyResponse,
2634
) {
27-
if (req.httpVersion === "1.0") {
28-
delete proxyRes.headers["transfer-encoding"];
29-
}
35+
// transfer-encoding is hop-by-hop, don't preserve it across proxy hops
36+
delete proxyRes.headers["transfer-encoding"];
3037
}
3138

3239
// If is a HTTP 1.0 request, set the correct connection header
3340
// or if connection header not present, then use `keep-alive`
3441
export function setConnection(
3542
req: Request,
36-
_res: Response,
43+
_res: EditableResponse,
3744
// Response object from the proxy request
3845
proxyRes: ProxyResponse,
3946
) {
@@ -46,7 +53,7 @@ export function setConnection(
4653

4754
export function setRedirectHostRewrite(
4855
req: Request,
49-
_res: Response,
56+
_res: EditableResponse,
5057
proxyRes: ProxyResponse,
5158
options: NormalizedServerOptions & { target: NormalizeProxyTarget<ProxyTarget> },
5259
) {
@@ -84,7 +91,7 @@ export function setRedirectHostRewrite(
8491
export function writeHeaders(
8592
_req: Request,
8693
// Response to set headers in
87-
res: Response,
94+
res: EditableResponse,
8895
// Response object from the proxy request
8996
proxyRes: ProxyResponse,
9097
// options.cookieDomainRewrite: Config to rewrite cookie domain
@@ -147,7 +154,7 @@ export function writeHeaders(
147154
// Set the statusCode from the proxyResponse
148155
export function writeStatusCode(
149156
_req: Request,
150-
res: Response,
157+
res: EditableResponse,
151158
proxyRes: ProxyResponse,
152159
) {
153160
// From Node.js docs: response.writeHead(statusCode[, statusMessage][, headers])

lib/http-proxy/passes/ws-incoming.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ The names of passes are exported as WS_PASSES from this module.
1111
import * as http from "node:http";
1212
import * as https from "node:https";
1313
import * as common from "../common";
14-
import type { Request } from "./web-incoming";
14+
import type { Request, ProxyResponse } from "./web-incoming";
15+
import { OUTGOING_PASSES, EditableResponse } from "./web-outgoing";
1516
import type { Socket } from "node:net";
1617
import debug from "debug";
17-
import type { NormalizedServerOptions, ProxyServer } from "..";
18+
import type { NormalizedServerOptions, NormalizeProxyTarget, ProxyServer, ProxyTarget } from "..";
1819

1920
const log = debug("http-proxy-3:ws-incoming");
21+
const web_o = Object.values(OUTGOING_PASSES);
2022

2123
function createSocketCounter(name: string) {
2224
let sockets = new Set<number>();
@@ -55,6 +57,26 @@ function createSocketCounter(name: string) {
5557
const socketCounter = createSocketCounter("socket");
5658
const proxySocketCounter = createSocketCounter("proxySocket");
5759

60+
/* MockResponse
61+
when a websocket gets a regular HTTP Response,
62+
apply proxied headers
63+
*/
64+
class MockResponse implements EditableResponse {
65+
constructor() {
66+
this.headers = {};
67+
this.statusCode = 200
68+
this.statusMessage = "";
69+
}
70+
public headers: { [key: string]: string};
71+
public statusCode: number;
72+
public statusMessage: string;
73+
74+
setHeader(key: string, value: string) {
75+
this.headers[key] = value;
76+
return this;
77+
};
78+
}
79+
5880
export function numOpenSockets(): number {
5981
return socketCounter() + proxySocketCounter();
6082
}
@@ -220,6 +242,57 @@ export function stream(
220242
socket.destroySoon();
221243
}
222244

245+
// if we get a response, backend is not a websocket endpoint,
246+
// relay HTTP response and close the socket
247+
proxyReq.on("response", (proxyRes: ProxyResponse) => {
248+
log("got non-ws HTTP response",
249+
{
250+
statusCode: proxyRes.statusCode,
251+
statusMessage: proxyRes.statusMessage,
252+
}
253+
);
254+
255+
const res = new MockResponse();
256+
for (const pass of web_o) {
257+
// note: none of these return anything
258+
pass(req, res as EditableResponse, proxyRes, options as NormalizedServerOptions & { target: NormalizeProxyTarget<ProxyTarget> });
259+
}
260+
261+
// implement HTTP/1.1 chunked transfer unless content-length is defined
262+
// matches proxyRes.pipe(res) behavior,
263+
// but we are piping directly to the socket instead, so it's our job.
264+
let writeChunk = (chunk: Buffer | string) => {
265+
socket.write(chunk);
266+
}
267+
if (req.httpVersion === "1.1" && proxyRes.headers["content-length"] === undefined) {
268+
res.headers["transfer-encoding"] = "chunked";
269+
writeChunk = (chunk: Buffer | string) => {
270+
socket.write(chunk.length.toString(16));
271+
socket.write("\r\n");
272+
socket.write(chunk);
273+
socket.write("\r\n");
274+
}
275+
}
276+
277+
const proxyHead = createHttpHeader(
278+
`HTTP/${req.httpVersion} ${proxyRes.statusCode} ${proxyRes.statusMessage}`,
279+
res.headers,
280+
);
281+
if (!socket.destroyed) {
282+
socket.write(proxyHead);
283+
proxyRes.on("data", (chunk) => {
284+
writeChunk(chunk);
285+
})
286+
proxyRes.on("end", () => {
287+
writeChunk("");
288+
socket.destroySoon();
289+
})
290+
} else {
291+
// make sure response is consumed
292+
proxyRes.resume();
293+
}
294+
});
295+
223296
proxyReq.end();
224297
}
225298

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
pnpm test websocket-proxy-http.test.ts
3+
4+
Test clients connecting a websocket to a non-websocket backend.
5+
The connection should fail promptly and preserve the original error code.
6+
7+
See https://nodejs.org/api/http.html#event-response
8+
9+
DEVELOPMENT:
10+
11+
pnpm test websocket-proxy-http.test.ts
12+
*/
13+
14+
import * as http from "node:http";
15+
import * as httpProxy from "../..";
16+
import log from "../log";
17+
import getPort from "../get-port";
18+
import { once } from "node:events";
19+
import {describe, it, expect, beforeAll, afterAll} from "vitest";
20+
21+
describe("Example of client requesting websocket when backend is plain http", () => {
22+
let ports: Record<'httpOnly' | 'proxy', number>;
23+
beforeAll(async () => {
24+
// assigns ports
25+
ports = { httpOnly: await getPort(), proxy: await getPort() };
26+
});
27+
28+
let servers: any = {};
29+
30+
it("Create an http server that doesn't support websockets", async () => {
31+
const server = http.createServer((_req, res) => {
32+
res.writeHead(418, { "Content-Type": "text/plain" });
33+
res.end("not a websocket!");
34+
});
35+
36+
servers.httpOnly = server;
37+
server.listen(ports.httpOnly);
38+
});
39+
40+
it("Try a websocket client connecting to a regular HTTP backend", async () => {
41+
const options = {
42+
port: ports.httpOnly,
43+
host: "localhost",
44+
headers: {
45+
Connection: "Upgrade",
46+
Upgrade: "websocket",
47+
},
48+
};
49+
const req = http.request(options);
50+
req.end();
51+
const [res] = await once(req, "response");
52+
expect(res.statusCode).toEqual(418);
53+
await once(res, "readable");
54+
const body = res.read().toString();
55+
expect(body.trim()).toEqual("not a websocket!");
56+
log("we got an http response.");
57+
});
58+
59+
it("Create a proxy server pointed at the non-websocket server, expecting websockets", async () => {
60+
servers.proxy = httpProxy
61+
.createServer({ target: `ws://localhost:${ports.httpOnly}`, ws: true })
62+
.listen(ports.proxy);
63+
});
64+
65+
it("Create a websocket client and test the proxy server", async () => {
66+
const options = {
67+
port: ports.proxy,
68+
host: "localhost",
69+
headers: {
70+
Connection: "Upgrade",
71+
Upgrade: "websocket",
72+
},
73+
};
74+
const req = http.request(options);
75+
req.end();
76+
const [res] = await once(req, "response");
77+
expect(res.statusCode).toEqual(418);
78+
await once(res, "readable");
79+
const body = res.read().toString();
80+
expect(body.trim()).toEqual("not a websocket!");
81+
});
82+
83+
afterAll(async () => {
84+
// cleans up
85+
Object.values(servers).map((x: any) => x?.close());
86+
});
87+
});

0 commit comments

Comments
 (0)