CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/hub/proxy/handle-upgrade.ts
Views: 687
1
// Websocket support
2
3
import { createProxyServer } from "http-proxy";
4
import LRU from "lru-cache";
5
import { getEventListeners } from "node:events";
6
7
import getLogger from "@cocalc/hub/logger";
8
import stripRememberMeCookie from "./strip-remember-me-cookie";
9
import { getTarget } from "./target";
10
import { stripBasePath } from "./util";
11
import { versionCheckFails } from "./version";
12
13
const logger = getLogger("proxy:handle-upgrade");
14
15
export default function init(
16
{ projectControl, isPersonal, httpServer, listenersHack },
17
proxy_regexp: string,
18
) {
19
const cache = new LRU({
20
max: 5000,
21
ttl: 1000 * 60 * 3,
22
});
23
24
const re = new RegExp(proxy_regexp);
25
26
async function handleProxyUpgradeRequest(req, socket, head): Promise<void> {
27
socket.on("error", (err) => {
28
// server will crash sometimes without this:
29
logger.debug("WARNING -- websocket socket error", err);
30
});
31
const dbg = (...args) => {
32
logger.silly(req.url, ...args);
33
};
34
dbg("got upgrade request from url=", req.url);
35
if (!req.url.match(re)) {
36
throw Error(`url=${req.url} does not support upgrade`);
37
}
38
39
// Check that minimum version requirement is satisfied (this is in the header).
40
// This is to have a way to stop buggy clients from causing trouble. It's a purely
41
// honor system sort of thing, but makes it possible for an admin to block clients
42
// until they run newer code. I used to have to use this a lot long ago...
43
if (versionCheckFails(req)) {
44
throw Error("client version check failed");
45
}
46
47
const url = stripBasePath(req.url);
48
49
let remember_me, api_key;
50
if (req.headers["cookie"] != null) {
51
let cookie;
52
({ cookie, remember_me, api_key } = stripRememberMeCookie(
53
req.headers["cookie"],
54
));
55
req.headers["cookie"] = cookie;
56
}
57
58
dbg("calling getTarget");
59
const { host, port, internal_url } = await getTarget({
60
url,
61
isPersonal,
62
projectControl,
63
remember_me,
64
api_key,
65
});
66
dbg("got ", { host, port });
67
68
const target = `ws://${host}:${port}`;
69
if (internal_url != null) {
70
req.url = internal_url;
71
}
72
if (cache.has(target)) {
73
dbg("using cache");
74
const proxy = cache.get(target);
75
(proxy as any)?.ws(req, socket, head);
76
return;
77
}
78
79
dbg("target", target);
80
dbg("not using cache");
81
const proxy = createProxyServer({
82
ws: true,
83
target,
84
timeout: 3000,
85
});
86
cache.set(target, proxy);
87
88
// taken from https://github.com/http-party/node-http-proxy/issues/1401
89
proxy.on("proxyRes", function (proxyRes) {
90
//console.log(
91
// "Raw [target] response",
92
// JSON.stringify(proxyRes.headers, true, 2)
93
//);
94
95
proxyRes.headers["x-reverse-proxy"] = "custom-proxy";
96
proxyRes.headers["cache-control"] = "no-cache, no-store";
97
98
//console.log(
99
// "Updated [proxied] response",
100
// JSON.stringify(proxyRes.headers, true, 2)
101
//);
102
});
103
104
proxy.on("error", (err) => {
105
logger.debug(`websocket proxy error, so clearing cache -- ${err}`);
106
cache.delete(target);
107
});
108
proxy.on("close", () => {
109
dbg("websocket proxy closed, so removing from cache");
110
cache.delete(target);
111
});
112
proxy.ws(req, socket, head);
113
}
114
115
let handler;
116
if (listenersHack) {
117
// This is an insane horrible hack to fix https://github.com/sagemathinc/cocalc/issues/7067
118
// The problem is that there are four separate websocket "upgrade" handlers when we are doing
119
// development, and nodejs just doesn't have a good solution to multiple websocket handlers,
120
// as explained here: https://github.com/nodejs/node/issues/6339
121
// The four upgrade handlers are:
122
// - this proxy here
123
// - the main hub primus one
124
// - the HMR reloader for that static webpack server for the app
125
// - the HMR reloader for nextjs
126
// These all just sort of randomly fight for any incoming "upgrade" event,
127
// and if they don't like it, tend to try to kill the socket. It's totally insane.
128
// What's worse is that getEventListeners only seems to ever return *two*
129
// listeners. By extensive trial and error, it seems to return first the primus
130
// listener, then the nextjs one. I have no idea why the order is that way; I would
131
// expect the reverse. (Update: it's because nextjs uses a hack -- it only installs
132
// a listener once a request comes in. Until there is a request, nextjs does not have
133
// access to the server and can't mess with it.)
134
// And I don't know why this handler here isn't in the list.
135
// In any case, once we get a failed request *and* we see there are at least two
136
// other handlers (it's exactly two), we completely steal handling of the upgrade
137
// event here. We then call the appropriate other handler when needed.
138
// I have no idea how the HMR reloader for that static webpack plays into this,
139
// but it appears to just work for some reason.
140
141
// NOTE: I had to do something similar that is in packages/next/lib/init.js,
142
// and is NOT a hack. That technique could probably be used to fix this properly.
143
144
let listeners: any[] = [];
145
handler = async (req, socket, head) => {
146
logger.debug("Proxy websocket handling -- using listenersHack");
147
try {
148
await handleProxyUpgradeRequest(req, socket, head);
149
} catch (err) {
150
if (listeners.length == 0) {
151
const x = getEventListeners(httpServer, "upgrade");
152
if (x.length >= 2) {
153
logger.debug(
154
"Proxy websocket handling -- installing listenersHack",
155
);
156
listeners = [...x];
157
httpServer.removeAllListeners("upgrade");
158
httpServer.on("upgrade", handler);
159
}
160
}
161
if (req.url.includes("hub?_primus") && listeners.length >= 2) {
162
listeners[0](req, socket, head);
163
return;
164
}
165
if (req.url.includes("_next/webpack-hmr") && listeners.length >= 2) {
166
listeners[1](req, socket, head);
167
return;
168
}
169
const msg = `WARNING: error upgrading websocket url=${req.url} -- ${err}`;
170
logger.debug(msg);
171
denyUpgrade(socket);
172
}
173
};
174
} else {
175
handler = async (req, socket, head) => {
176
try {
177
await handleProxyUpgradeRequest(req, socket, head);
178
} catch (err) {
179
const msg = `WARNING: error upgrading websocket url=${req.url} -- ${err}`;
180
logger.debug(msg);
181
denyUpgrade(socket);
182
}
183
};
184
}
185
186
return handler;
187
}
188
189
function denyUpgrade(socket) {
190
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
191
socket.destroy();
192
}
193
194