Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/http-proxy/passes/web-incoming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The names of passes are exported as WEB_PASSES from this module.

import * as http from "node:http";
import * as https from "node:https";
import { OUTGOING_PASSES } from "./web-outgoing";
import { OUTGOING_PASSES, EditableResponse } from "./web-outgoing";
import * as common from "../common";
import * as followRedirects from "follow-redirects";
import {
Expand Down Expand Up @@ -164,7 +164,7 @@ export function stream(req: Request, res: Response, options: NormalizedServerOpt
if (!res.headersSent && !options.selfHandleResponse) {
for (const pass of web_o) {
// note: none of these return anything
pass(req, res, proxyRes, options as NormalizedServerOptions & { target: NormalizeProxyTarget<ProxyTarget> });
pass(req, res as EditableResponse, proxyRes, options as NormalizedServerOptions & { target: NormalizeProxyTarget<ProxyTarget> });
}
}

Expand Down
27 changes: 17 additions & 10 deletions lib/http-proxy/passes/web-outgoing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,36 @@ to not return anything.

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

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

// interface for subset of Response that's actually used here
// needed for running outgoing passes on MockResponse in ws-incoming
export interface EditableResponse {
statusCode: number;
statusMessage: string;
setHeader(key: string, value: string | string[]): this;
}

// <--

// If is a HTTP 1.0 request, remove chunk headers
export function removeChunked(
req: Request,
_res: Response,
_req: Request,
_res: EditableResponse,
// Response object from the proxy request
proxyRes: ProxyResponse,
) {
if (req.httpVersion === "1.0") {
delete proxyRes.headers["transfer-encoding"];
}
// transfer-encoding is hop-by-hop, don't preserve it across proxy hops
delete proxyRes.headers["transfer-encoding"];
}

// If is a HTTP 1.0 request, set the correct connection header
// or if connection header not present, then use `keep-alive`
export function setConnection(
req: Request,
_res: Response,
_res: EditableResponse,
// Response object from the proxy request
proxyRes: ProxyResponse,
) {
Expand All @@ -46,7 +53,7 @@ export function setConnection(

export function setRedirectHostRewrite(
req: Request,
_res: Response,
_res: EditableResponse,
proxyRes: ProxyResponse,
options: NormalizedServerOptions & { target: NormalizeProxyTarget<ProxyTarget> },
) {
Expand Down Expand Up @@ -84,7 +91,7 @@ export function setRedirectHostRewrite(
export function writeHeaders(
_req: Request,
// Response to set headers in
res: Response,
res: EditableResponse,
// Response object from the proxy request
proxyRes: ProxyResponse,
// options.cookieDomainRewrite: Config to rewrite cookie domain
Expand Down Expand Up @@ -147,7 +154,7 @@ export function writeHeaders(
// Set the statusCode from the proxyResponse
export function writeStatusCode(
_req: Request,
res: Response,
res: EditableResponse,
proxyRes: ProxyResponse,
) {
// From Node.js docs: response.writeHead(statusCode[, statusMessage][, headers])
Expand Down
77 changes: 75 additions & 2 deletions lib/http-proxy/passes/ws-incoming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ The names of passes are exported as WS_PASSES from this module.
import * as http from "node:http";
import * as https from "node:https";
import * as common from "../common";
import type { Request } from "./web-incoming";
import type { Request, ProxyResponse } from "./web-incoming";
import { OUTGOING_PASSES, EditableResponse } from "./web-outgoing";
import type { Socket } from "node:net";
import debug from "debug";
import type { NormalizedServerOptions, ProxyServer } from "..";
import type { NormalizedServerOptions, NormalizeProxyTarget, ProxyServer, ProxyTarget } from "..";

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

function createSocketCounter(name: string) {
let sockets = new Set<number>();
Expand Down Expand Up @@ -55,6 +57,26 @@ function createSocketCounter(name: string) {
const socketCounter = createSocketCounter("socket");
const proxySocketCounter = createSocketCounter("proxySocket");

/* MockResponse
when a websocket gets a regular HTTP Response,
apply proxied headers
*/
class MockResponse implements EditableResponse {
constructor() {
this.headers = {};
this.statusCode = 200
this.statusMessage = "";
}
public headers: { [key: string]: string};
public statusCode: number;
public statusMessage: string;

setHeader(key: string, value: string) {
this.headers[key] = value;
return this;
};
}

export function numOpenSockets(): number {
return socketCounter() + proxySocketCounter();
}
Expand Down Expand Up @@ -220,6 +242,57 @@ export function stream(
socket.destroySoon();
}

// if we get a response, backend is not a websocket endpoint,
// relay HTTP response and close the socket
proxyReq.on("response", (proxyRes: ProxyResponse) => {
log("got non-ws HTTP response",
{
statusCode: proxyRes.statusCode,
statusMessage: proxyRes.statusMessage,
}
);

const res = new MockResponse();
for (const pass of web_o) {
// note: none of these return anything
pass(req, res as EditableResponse, proxyRes, options as NormalizedServerOptions & { target: NormalizeProxyTarget<ProxyTarget> });
}

// implement HTTP/1.1 chunked transfer unless content-length is defined
// matches proxyRes.pipe(res) behavior,
// but we are piping directly to the socket instead, so it's our job.
let writeChunk = (chunk: Buffer | string) => {
socket.write(chunk);
}
if (req.httpVersion === "1.1" && proxyRes.headers["content-length"] === undefined) {
res.headers["transfer-encoding"] = "chunked";
writeChunk = (chunk: Buffer | string) => {
socket.write(chunk.length.toString(16));
socket.write("\r\n");
socket.write(chunk);
socket.write("\r\n");
}
}

const proxyHead = createHttpHeader(
`HTTP/${req.httpVersion} ${proxyRes.statusCode} ${proxyRes.statusMessage}`,
res.headers,
);
if (!socket.destroyed) {
socket.write(proxyHead);
proxyRes.on("data", (chunk) => {
writeChunk(chunk);
})
proxyRes.on("end", () => {
writeChunk("");
socket.destroySoon();
})
} else {
// make sure response is consumed
proxyRes.resume();
}
});

proxyReq.end();
}

Expand Down
87 changes: 87 additions & 0 deletions lib/test/websocket/websocket-proxy-http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
pnpm test websocket-proxy-http.test.ts

Test clients connecting a websocket to a non-websocket backend.
The connection should fail promptly and preserve the original error code.

See https://nodejs.org/api/http.html#event-response

DEVELOPMENT:

pnpm test websocket-proxy-http.test.ts
*/

import * as http from "node:http";
import * as httpProxy from "../..";
import log from "../log";
import getPort from "../get-port";
import { once } from "node:events";
import {describe, it, expect, beforeAll, afterAll} from "vitest";

describe("Example of client requesting websocket when backend is plain http", () => {
let ports: Record<'httpOnly' | 'proxy', number>;
beforeAll(async () => {
// assigns ports
ports = { httpOnly: await getPort(), proxy: await getPort() };
});

let servers: any = {};

it("Create an http server that doesn't support websockets", async () => {
const server = http.createServer((_req, res) => {
res.writeHead(418, { "Content-Type": "text/plain" });
res.end("not a websocket!");
});

servers.httpOnly = server;
server.listen(ports.httpOnly);
});

it("Try a websocket client connecting to a regular HTTP backend", async () => {
const options = {
port: ports.httpOnly,
host: "localhost",
headers: {
Connection: "Upgrade",
Upgrade: "websocket",
},
};
const req = http.request(options);
req.end();
const [res] = await once(req, "response");
expect(res.statusCode).toEqual(418);
await once(res, "readable");
const body = res.read().toString();
expect(body.trim()).toEqual("not a websocket!");
log("we got an http response.");
});

it("Create a proxy server pointed at the non-websocket server, expecting websockets", async () => {
servers.proxy = httpProxy
.createServer({ target: `ws://localhost:${ports.httpOnly}`, ws: true })
.listen(ports.proxy);
});

it("Create a websocket client and test the proxy server", async () => {
const options = {
port: ports.proxy,
host: "localhost",
headers: {
Connection: "Upgrade",
Upgrade: "websocket",
},
};
const req = http.request(options);
req.end();
const [res] = await once(req, "response");
expect(res.statusCode).toEqual(418);
await once(res, "readable");
const body = res.read().toString();
expect(body.trim()).toEqual("not a websocket!");
});

afterAll(async () => {
// cleans up
Object.values(servers).map((x: any) => x?.close());
});
});