Skip to content

Commit b12b304

Browse files
authored
feat: add websocket support
1 parent 17bc7f7 commit b12b304

File tree

3 files changed

+158
-53
lines changed

3 files changed

+158
-53
lines changed

README.md

+6-3
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ This activates the HTTP Basic authentication. The value on this field should be
7373
**fallback:**
7474

7575
If you want to use the proxy instance as a middleware, add `fallback` as an option, with a function that can handle a request.
76-
When `server.handleRequest` is called, and no proxy entries we matched by a request, the fallback function will be called instead.
76+
When `server.onRequest` is called, and no proxy entries we matched by a request, the fallback function will be called instead.
7777

78-
## Usage
78+
## API
7979

8080
```ts
8181
import { ProxyServer, ProxySettings, ProxyEntry } from '@cloud-cli/proxy';
@@ -103,7 +103,10 @@ server.reset();
103103
server.reload();
104104

105105
// OPTIONAL: handle a request coming from another http(s) server
106-
server.handleRequest(request, response, /* isSSL */ false);
106+
server.onRequest(request, response, /* isSSL */ false);
107+
108+
// OPTIONAL: handle a request coming from a websocket upgrade
109+
server.onUpgrade(request, socket, head, /* isSSL */ false);
107110
```
108111

109112
## Example

src/index.spec.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe('ProxyServer', () => {
9696
const { req, res, promise } = createRequest('GET', new URL('http://example.com/notFound'));
9797

9898
await server.start();
99-
server.handleRequest(req, res, false);
99+
server.onRequest(req, res, false);
100100

101101
await promise;
102102

@@ -138,7 +138,7 @@ describe('ProxyServer', () => {
138138
authorization: 'dGVzdDp0ZXN0',
139139
});
140140

141-
server.handleRequest(req, res, false);
141+
server.onRequest(req, res, false);
142142
await promise;
143143

144144
expect(res.writeHead).toHaveBeenCalledWith(401);
@@ -159,7 +159,7 @@ describe('ProxyServer', () => {
159159
authorization: 'dGVzdDp0ZXN0',
160160
});
161161

162-
server.handleRequest(req, res, false);
162+
server.onRequest(req, res, false);
163163
await promise;
164164

165165
expect(res.headers['WWW-Authenticate']).not.toBeDefined();
@@ -178,7 +178,7 @@ describe('ProxyServer', () => {
178178
redirectToHttps: false,
179179
});
180180

181-
server.handleRequest(req, res, false);
181+
server.onRequest(req, res, false);
182182

183183
events.data('OK');
184184
events.end();
@@ -206,7 +206,7 @@ describe('ProxyServer', () => {
206206
redirectToHttps: true,
207207
});
208208

209-
server.handleRequest(req, res, false);
209+
server.onRequest(req, res, false);
210210

211211
await promise;
212212

@@ -228,7 +228,7 @@ describe('ProxyServer', () => {
228228
redirectToDomain: 'redirect.com',
229229
});
230230

231-
server.handleRequest(req, res, false);
231+
server.onRequest(req, res, false);
232232

233233
events.data('OK');
234234
events.end();
@@ -253,7 +253,7 @@ describe('ProxyServer', () => {
253253
redirectToUrl: 'http://another.example.com/foo',
254254
});
255255

256-
server.handleRequest(req, res, false);
256+
server.onRequest(req, res, false);
257257

258258
events.data('OK');
259259
events.end();
@@ -281,7 +281,7 @@ describe('ProxyServer', () => {
281281
cors: true,
282282
});
283283

284-
server.handleRequest(req, res, false);
284+
server.onRequest(req, res, false);
285285
await promise;
286286

287287
expect(res.writeHead).toHaveBeenCalledWith(204, { 'Content-Length': '0' });
@@ -297,7 +297,7 @@ describe('ProxyServer', () => {
297297
const { req, res, promise } = createRequest('GET', new URL('http://example.com/test'));
298298

299299
await server.start();
300-
server.handleRequest(req, res, false);
300+
server.onRequest(req, res, false);
301301
await promise;
302302

303303
expect(fallback).toHaveBeenCalledWith(req, res);

src/index.ts

+143-41
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
IncomingMessage,
55
ClientRequest,
66
ServerResponse,
7+
IncomingHttpHeaders,
78
} from 'node:http';
89
import {
910
createServer as createHttpsServer,
@@ -16,6 +17,7 @@ import { readdir, readFile } from 'node:fs/promises';
1617
import { join } from 'node:path';
1718
import { EventEmitter } from 'node:events';
1819
import { existsSync } from 'node:fs';
20+
import { Socket } from 'node:net';
1921

2022
export class ProxyEntry {
2123
readonly domain: string;
@@ -41,7 +43,7 @@ interface ProxySettingsFromFile extends Partial<ProxySettings> {
4143
export type MinimalProxyEntry = Partial<ProxyEntry> & Pick<ProxyEntry, 'domain'>;
4244

4345
export class ProxySettings {
44-
readonly certificatesFolder: string = String(process.env.PROXY_CERTS_FOLDER);
46+
readonly certificatesFolder: string = String(process.env.PROXY_CERTS_FOLDER || process.cwd());
4547
readonly certificateFile: string = 'fullchain.pem';
4648
readonly keyFile: string = 'privkey.pem';
4749
readonly httpPort: number = Number(process.env.HTTP_PORT) || 80;
@@ -56,6 +58,12 @@ export class ProxySettings {
5658
}
5759
}
5860

61+
export type ProxyIncomingMessage = IncomingMessage & {
62+
originHost: string;
63+
originlUrl: URL | null;
64+
proxyEntry: ProxyEntry;
65+
};
66+
5967
export class ProxyServer extends EventEmitter {
6068
protected certs: Record<string, SecureContext> = {};
6169
protected proxies: Array<MinimalProxyEntry> = [];
@@ -79,8 +87,8 @@ export class ProxyServer extends EventEmitter {
7987
const ssl = this.getSslOptions();
8088

8189
this.servers = [
82-
httpPort && createHttpServer((req, res) => this.handleRequest(req, res, false)).listen(httpPort),
83-
httpsPort && createHttpsServer(ssl, (req, res) => this.handleRequest(req, res, true)).listen(httpsPort),
90+
httpPort && this.setupServer(createHttpServer(), false).listen(httpPort),
91+
httpsPort && this.setupServer(createHttpsServer(ssl), true).listen(httpsPort),
8492
].filter(Boolean);
8593
}
8694

@@ -115,31 +123,134 @@ export class ProxyServer extends EventEmitter {
115123
return this;
116124
}
117125

118-
handleRequest(req: IncomingMessage, res: ServerResponse, isSsl?: boolean) {
126+
onRequest(_req: IncomingMessage, res: ServerResponse, isSsl: boolean) {
127+
const req = this.matchProxy(_req);
128+
const { proxyEntry } = req;
129+
130+
if (!proxyEntry) {
131+
return;
132+
}
133+
134+
const proxyRequest = this.createRequest(req, res, isSsl);
135+
136+
if (!proxyRequest) {
137+
return;
138+
}
139+
140+
req.on('data', (chunk) => proxyRequest.write(chunk));
141+
req.on('end', () => proxyRequest.end());
142+
143+
proxyRequest.on('error', (error) => this.handleError(error, res));
144+
proxyRequest.on('response', (proxyRes) => {
145+
this.setHeaders(proxyRes, res);
146+
147+
const isCorsSimple = req.method !== 'OPTIONS' && proxyEntry.cors && req.headers.origin;
148+
if (isCorsSimple) {
149+
this.setCorsHeaders(req, res);
150+
}
151+
152+
res.writeHead(proxyRes.statusCode, proxyRes.statusMessage);
153+
154+
proxyRes.on('data', (chunk) => res.write(chunk));
155+
proxyRes.on('end', () => res.end());
156+
});
157+
}
158+
159+
onUpgrade(_req: IncomingMessage, socket: Socket, head: any, isSsl: boolean) {
160+
const notValid = _req.method !== 'GET' || !_req.headers.upgrade || _req.headers.upgrade.toLowerCase() !== 'websocket';
161+
const req = this.matchProxy(_req);
162+
163+
if (notValid || !req.proxyEntry) {
164+
socket.destroy();
165+
return;
166+
}
167+
168+
const proxyReq = this.createRequest(req, socket as any, isSsl);
169+
170+
socket.setTimeout(0);
171+
socket.setNoDelay(true);
172+
socket.setKeepAlive(true, 0);
173+
174+
if (head && head.length) {
175+
socket.unshift(head);
176+
}
177+
178+
proxyReq.on('error', (error) => this.emit('proxyerror', error));
179+
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
180+
proxySocket.on('error', (error) => this.emit('proxyerror', error));
181+
182+
socket.on('error', (error) => {
183+
this.emit('proxyerror', error);
184+
proxySocket.end();
185+
});
186+
187+
if (proxyHead && proxyHead.length) {
188+
proxySocket.unshift(proxyHead);
189+
}
190+
191+
socket.write(this.createWebSocketResponseHeaders(proxyRes.headers));
192+
193+
proxySocket.pipe(socket).pipe(proxySocket);
194+
});
195+
196+
return proxyReq.end();
197+
}
198+
199+
protected createWebSocketResponseHeaders(headers: IncomingHttpHeaders) {
200+
return (
201+
Object.entries(headers)
202+
.reduce(
203+
function (head, next) {
204+
const [key, value] = next;
205+
206+
if (!Array.isArray(value)) {
207+
head.push(`${key}: ${value}`);
208+
return head;
209+
}
210+
211+
for (const next of value) {
212+
head.push(`${key}: ${next}`);
213+
}
214+
215+
return head;
216+
},
217+
['HTTP/1.1 101 Switching Protocols']
218+
)
219+
.join('\r\n') + '\r\n\r\n'
220+
);
221+
}
222+
223+
protected matchProxy(req: IncomingMessage) {
119224
const originHost = [req.headers['x-forwarded-for'], req.headers.host].filter(Boolean)[0];
120225
const originlUrl = originHost ? new URL('http://' + originHost) : null;
121226
const proxyEntry = originlUrl ? this.findProxyEntry(originlUrl.hostname, req.url) : null;
122227

228+
Object.assign(req, { originHost, originlUrl, proxyEntry });
229+
230+
return req as ProxyIncomingMessage;
231+
}
232+
233+
protected createRequest(req: ProxyIncomingMessage, res: ServerResponse, isSsl: boolean) {
234+
const { originHost, originlUrl, proxyEntry } = req;
235+
123236
if (this.settings.enableDebug) {
124-
const _end = res.end;
125-
res.end = (...args) => {
237+
res.on('finish', () => {
126238
console.log(
127239
'[%s] %s %s [%s] => %d %s',
128240
new Date().toISOString().slice(0, 19),
129241
req.method,
130242
req.url,
131243
originHost,
132244
res.statusCode,
133-
proxyEntry?.target || '(none)',
245+
proxyEntry?.target || '(none)'
134246
);
135-
136-
return _end.apply(res, args);
137-
};
247+
});
138248
}
139249

140250
if (!(originlUrl && proxyEntry)) {
141251
if (this.settings.fallback) {
142-
return this.settings.fallback(req, res);
252+
this.settings.fallback(req, res);
253+
return;
143254
}
144255

145256
res.writeHead(404, 'Not found');
@@ -199,7 +310,8 @@ export class ProxyServer extends EventEmitter {
199310
targetUrl.pathname = targetUrl.pathname.replace(proxyEntry.path, '');
200311
}
201312

202-
const proxyRequest = (targetUrl.protocol === 'https:' ? httpsRequest : httpRequest)(targetUrl, { method: req.method });
313+
const requestOptions = { method: req.method };
314+
const proxyRequest = (targetUrl.protocol === 'https:' ? httpsRequest : httpRequest)(targetUrl, requestOptions);
203315
this.setHeaders(req, proxyRequest);
204316

205317
if (proxyEntry.headers) {
@@ -217,23 +329,14 @@ export class ProxyServer extends EventEmitter {
217329
proxyRequest.setHeader('x-forwarded-proto', isSsl ? 'https' : 'http');
218330
proxyRequest.setHeader('forwarded', 'host=' + req.headers.host + ';proto=' + (isSsl ? 'https' : 'http'));
219331

220-
req.on('data', (chunk) => proxyRequest.write(chunk));
221-
req.on('end', () => proxyRequest.end());
222-
223-
proxyRequest.on('error', (error) => this.handleError(error, res));
224-
proxyRequest.on('response', (proxyRes) => {
225-
this.setHeaders(proxyRes, res);
226-
227-
const isCorsSimple = req.method !== 'OPTIONS' && proxyEntry.cors && req.headers.origin;
228-
if (isCorsSimple) {
229-
this.setCorsHeaders(req, res);
230-
}
332+
return proxyRequest;
333+
}
231334

232-
res.writeHead(proxyRes.statusCode, proxyRes.statusMessage);
335+
protected setupServer(server: any, isSsl: boolean) {
336+
server.on('request', (req, res) => this.onRequest(req, res, isSsl));
337+
server.on('upgrade', (req, socket, head) => this.onUpgrade(req, socket, head, isSsl));
233338

234-
proxyRes.on('data', (chunk) => res.write(chunk));
235-
proxyRes.on('end', () => res.end());
236-
});
339+
return server;
237340
}
238341

239342
protected async loadCertificate(folder: string) {
@@ -285,9 +388,8 @@ export class ProxyServer extends EventEmitter {
285388
const byDomain = this.proxies.filter(
286389
(p) =>
287390
p.domain === domainFromRequest ||
288-
(p.domain.startsWith("*.") &&
289-
(p.domain.slice(2) === requestParentDomain ||
290-
p.domain.slice(2) === domainFromRequest))
391+
(p.domain.startsWith('*.') &&
392+
(p.domain.slice(2) === requestParentDomain || p.domain.slice(2) === domainFromRequest))
291393
);
292394

293395
if (byDomain.length === 1) {
@@ -301,9 +403,11 @@ export class ProxyServer extends EventEmitter {
301403
// without path
302404
// example.com => [target]
303405

304-
return byDomain.find((p) => p.path && (requestPath === p.path || requestPath.startsWith(p.path + '/')))
305-
|| byDomain.find((p) => !p.path)
306-
|| null;
406+
return (
407+
byDomain.find((p) => p.path && (requestPath === p.path || requestPath.startsWith(p.path + '/'))) ||
408+
byDomain.find((p) => !p.path) ||
409+
null
410+
);
307411
}
308412

309413
protected getSslOptions(): HttpsServerOptions {
@@ -384,12 +488,12 @@ export class ProxyServer extends EventEmitter {
384488
export async function loadConfig(path?: string, optional = false): Promise<ProxySettingsFromFile> {
385489
if (!path) {
386490
const candidates = [
387-
join(process.cwd(), "proxy.config.mjs"),
388-
join(process.cwd(), "proxy.config.js"),
389-
join(process.cwd(), "proxy.config.json"),
491+
join(process.cwd(), 'proxy.config.mjs'),
492+
join(process.cwd(), 'proxy.config.js'),
493+
join(process.cwd(), 'proxy.config.json'),
390494
];
391495

392-
path = candidates.find(path => existsSync(path));
496+
path = candidates.find((path) => existsSync(path));
393497
}
394498

395499
if (!path || !existsSync(path)) {
@@ -401,9 +505,7 @@ export async function loadConfig(path?: string, optional = false): Promise<Proxy
401505
}
402506

403507
if (path.endsWith('.json')) {
404-
return new ProxySettings(
405-
JSON.parse(await readFile(path, "utf-8"))
406-
);
508+
return new ProxySettings(JSON.parse(await readFile(path, 'utf-8')));
407509
}
408510

409511
const mod = await import(path);
@@ -412,4 +514,4 @@ export async function loadConfig(path?: string, optional = false): Promise<Proxy
412514

413515
export function defineConfig(config: ProxySettings) {
414516
return new ProxySettings(config);
415-
}
517+
}

0 commit comments

Comments
 (0)