Skip to content

Commit 3b9fdea

Browse files
committed
hub: rewrite code for dealing with mutiple websocket upgrade handlers at once, since this broke with the nextjs upgrade
1 parent d45da07 commit 3b9fdea

File tree

7 files changed

+92
-102
lines changed

7 files changed

+92
-102
lines changed

src/packages/hub/hub.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,6 @@ async function startServer(): Promise<void> {
236236
nextServer: !!program.nextServer,
237237
cert: program.httpsCert,
238238
key: program.httpsKey,
239-
listenersHack:
240-
program.mode == "single-user" &&
241-
program.proxyServer &&
242-
program.nextServer &&
243-
process.env["NODE_ENV"] == "development",
244239
});
245240

246241
// The express app create via initExpressApp above **assumes** that init_passport is done

src/packages/hub/proxy/handle-upgrade.ts

Lines changed: 83 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ import { versionCheckFails } from "./version";
1111
import { proxyConatWebsocket } from "./proxy-conat";
1212
import basePath from "@cocalc/backend/base-path";
1313

14+
const LISTENERS_HACK = true;
15+
1416
const logger = getLogger("proxy:handle-upgrade");
1517

16-
export default function init(
17-
{ projectControl, isPersonal, httpServer, listenersHack, proxyConat },
18+
export default function initUpgrade(
19+
{ projectControl, isPersonal, httpServer, proxyConat },
1820
proxy_regexp: string,
1921
) {
2022
const cache = new LRU<string, ProxyServer>({
@@ -24,22 +26,72 @@ export default function init(
2426

2527
const re = new RegExp(proxy_regexp);
2628

29+
let nextUpgrade: undefined | Function = undefined;
30+
let socketioUpgrade: undefined | Function = undefined;
31+
2732
async function handleProxyUpgradeRequest(req, socket, head): Promise<void> {
28-
if (proxyConat) {
29-
const u = new URL(req.url, "http://cocalc.com");
30-
let pathname = u.pathname;
31-
if (basePath.length > 1) {
32-
pathname = pathname.slice(basePath.length);
33-
}
34-
if (pathname == "/conat/") {
35-
proxyConatWebsocket(req, socket, head);
36-
return;
33+
if (LISTENERS_HACK) {
34+
const v = getEventListeners(httpServer, "upgrade");
35+
if (v.length > 1) {
36+
// Nodejs basically assumes that there is only one listener for the "upgrade" handler,
37+
// but depending on how you run CoCalc, two others may get added:
38+
// - a socketio server
39+
// - a nextjs server
40+
// We check if anything extra got added and if so, identify it and properly
41+
// use it. We identify the handle using `${f}` and using a heuristic for the
42+
// code. That's the best I can do and it's obviously brittle.
43+
// Note: rspack for the static app doesn't use a websocket, instead using SSE, so
44+
// fortunately it's not relevant and hmr works fine. HMR for the nextjs server
45+
// tends to just refresh the page, probably because we're using rspack there too.
46+
for (const f of v) {
47+
if (f === handler) {
48+
// it's us -- leave it alone
49+
continue;
50+
}
51+
const source = `${f}`;
52+
logger.debug(`found extra listener`, { f, source });
53+
if (source.includes("destroyUpgrade")) {
54+
// WARNING/BRITTLE! the socketio source code for the upgrade handler has a destroyUpgrade
55+
// option it checks for, whereas the nextjs one doesn't.
56+
if (socketioUpgrade === undefined) {
57+
socketioUpgrade = f;
58+
} else {
59+
logger.debug(
60+
"WARNING! discovered unknown upgrade listener!",
61+
source,
62+
);
63+
}
64+
} else {
65+
if (nextUpgrade === undefined) {
66+
nextUpgrade = f;
67+
} else {
68+
logger.debug(
69+
"WARNING! discovered unknown upgrade listener!",
70+
source,
71+
);
72+
}
73+
}
74+
logger.debug(
75+
`found extra listener -- detected, saved and removed 'upgrade' listener`,
76+
source,
77+
);
78+
httpServer.removeListener("upgrade", f);
79+
}
3780
}
3881
}
3982

83+
if (proxyConat && useSocketio(req.url)) {
84+
proxyConatWebsocket(req, socket, head);
85+
return;
86+
}
87+
4088
if (!req.url.match(re)) {
41-
// something else (e.g., the socket.io server) is handling this websocket;
42-
// we do NOT mess with anything in this case
89+
// it's to be handled by socketio or next
90+
if (socketioUpgrade !== undefined && useSocketio(req.url)) {
91+
socketioUpgrade(req, socket, head);
92+
return;
93+
}
94+
nextUpgrade?.(req, socket, head);
4395
return;
4496
}
4597

@@ -128,80 +180,15 @@ export default function init(
128180
proxy.ws(req, socket, head);
129181
}
130182

131-
let handler;
132-
if (listenersHack) {
133-
// This is an insane horrible hack to fix https://github.com/sagemathinc/cocalc/issues/7067
134-
// The problem is that there are four separate websocket "upgrade" handlers when we are doing
135-
// development, and nodejs just doesn't have a good solution to multiple websocket handlers,
136-
// as explained here: https://github.com/nodejs/node/issues/6339
137-
// The four upgrade handlers are:
138-
// - this proxy here
139-
// - the main hub primus one
140-
// - the HMR reloader for that static webpack server for the app
141-
// - the HMR reloader for nextjs
142-
// These all just sort of randomly fight for any incoming "upgrade" event,
143-
// and if they don't like it, tend to try to kill the socket. It's totally insane.
144-
// What's worse is that getEventListeners only seems to ever return *two*
145-
// listeners. By extensive trial and error, it seems to return first the primus
146-
// listener, then the nextjs one. I have no idea why the order is that way; I would
147-
// expect the reverse. (Update: it's because nextjs uses a hack -- it only installs
148-
// a listener once a request comes in. Until there is a request, nextjs does not have
149-
// access to the server and can't mess with it.)
150-
// And I don't know why this handler here isn't in the list.
151-
// In any case, once we get a failed request *and* we see there are at least two
152-
// other handlers (it's exactly two), we completely steal handling of the upgrade
153-
// event here. We then call the appropriate other handler when needed.
154-
// I have no idea how the HMR reloader for that static webpack plays into this,
155-
// but it appears to just work for some reason.
156-
157-
// NOTE: I had to do something similar that is in packages/next/lib/init.js,
158-
// and is NOT a hack. That technique could probably be used to fix this properly.
159-
// NOTE2: It's May 2025, and I basically don't use HMR anymore and just refresh
160-
// my page, since dealing with this is so painful. Also rspack is superfast and
161-
// refresh is fast, so HMR feels less necessary. Finally, frequently any dev work
162-
// I do requires a page refresh anyways.
163-
164-
let listeners: any[] = [];
165-
handler = async (req, socket, head) => {
166-
logger.debug("Proxy websocket handling -- using listenersHack");
167-
try {
168-
await handleProxyUpgradeRequest(req, socket, head);
169-
} catch (err) {
170-
if (listeners.length == 0) {
171-
const x = getEventListeners(httpServer, "upgrade");
172-
if (x.length >= 2) {
173-
logger.debug(
174-
"Proxy websocket handling -- installing listenersHack",
175-
);
176-
listeners = [...x];
177-
httpServer.removeAllListeners("upgrade");
178-
httpServer.on("upgrade", handler);
179-
}
180-
}
181-
if (req.url.includes("hub?_primus") && listeners.length >= 2) {
182-
listeners[0](req, socket, head);
183-
return;
184-
}
185-
if (req.url.includes("_next/webpack-hmr") && listeners.length >= 2) {
186-
listeners[1](req, socket, head);
187-
return;
188-
}
189-
const msg = `WARNING: error upgrading websocket url=${req.url} -- ${err}`;
190-
logger.debug(msg);
191-
denyUpgrade(socket);
192-
}
193-
};
194-
} else {
195-
handler = async (req, socket, head) => {
196-
try {
197-
await handleProxyUpgradeRequest(req, socket, head);
198-
} catch (err) {
199-
const msg = `WARNING: error upgrading websocket url=${req.url} -- ${err}`;
200-
logger.debug(msg);
201-
denyUpgrade(socket);
202-
}
203-
};
204-
}
183+
const handler = async (req, socket, head) => {
184+
try {
185+
await handleProxyUpgradeRequest(req, socket, head);
186+
} catch (err) {
187+
const msg = `WARNING: error upgrading websocket url=${req.url} -- ${err}`;
188+
logger.debug(msg);
189+
denyUpgrade(socket);
190+
}
191+
};
205192

206193
return handler;
207194
}
@@ -210,3 +197,12 @@ function denyUpgrade(socket) {
210197
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
211198
socket.destroy();
212199
}
200+
201+
function useSocketio(url: string) {
202+
const u = new URL(url, "http://cocalc.com");
203+
let pathname = u.pathname;
204+
if (basePath.length > 1) {
205+
pathname = pathname.slice(basePath.length);
206+
}
207+
return pathname == "/conat/";
208+
}

src/packages/hub/proxy/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { Application } from "express";
77
import getLogger from "../logger";
8-
import initProxy from "./handle-request";
8+
import initRequest from "./handle-request";
99
import initUpgrade from "./handle-upgrade";
1010
import base_path from "@cocalc/backend/base-path";
1111
import { ProjectControlFunction } from "@cocalc/server/projects/control";
@@ -17,18 +17,17 @@ interface Options {
1717
httpServer; // got from express_app via httpServer = http.createServer(app).
1818
projectControl: ProjectControlFunction; // controls projects (aka "compute server")
1919
isPersonal: boolean; // if true, disables all access controls
20-
listenersHack: boolean;
2120
proxyConat: boolean;
2221
}
2322

24-
export default function init(opts: Options) {
23+
export default function initProxy(opts: Options) {
2524
const proxy_regexp = `^${
2625
base_path.length <= 1 ? "" : base_path
2726
}\/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}\/*`;
2827
logger.info("creating proxy server with proxy_regexp", proxy_regexp);
2928

3029
// tcp connections:
31-
const handleProxy = initProxy(opts);
30+
const handleProxy = initRequest(opts);
3231

3332
// websocket upgrades:
3433
const handleUpgrade = initUpgrade(opts, proxy_regexp);

src/packages/hub/servers/app/next.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import { join } from "path";
1111

1212
// @ts-ignore -- TODO: typescript doesn't like @cocalc/next/init (it is a js file).
1313
import initNextServer from "@cocalc/next/init";
14-
1514
import basePath from "@cocalc/backend/base-path";
1615
import { getLogger } from "@cocalc/hub/logger";
1716
import handleRaw from "@cocalc/next/lib/share/handle-raw";

src/packages/hub/servers/express-app.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ interface Options {
4545
conatServer: boolean;
4646
cert?: string;
4747
key?: string;
48-
listenersHack: boolean;
4948
}
5049

5150
export default async function init(opts: Options): Promise<{
@@ -150,19 +149,19 @@ export default async function init(opts: Options): Promise<{
150149
});
151150
}
152151

152+
// This must be second to the last, since it will prevent any
153+
// other upgrade handlers from being added to httpServer.
153154
if (opts.proxyServer) {
154155
winston.info(`initializing the http proxy server`, {
155156
conatSocketioCount,
156157
conatServer: !!opts.conatServer,
157158
isPersonal: opts.isPersonal,
158-
listenersHack: opts.listenersHack,
159159
});
160160
initProxy({
161161
projectControl: opts.projectControl,
162162
isPersonal: opts.isPersonal,
163163
httpServer,
164164
app,
165-
listenersHack: opts.listenersHack,
166165
// enable proxy server for /conat if:
167166
// (1) we are not running conat at all from here, or
168167
// (2) we are running socketio in cluster mode, hence

src/packages/next/next.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const config = {
3131
"node_modules",
3232
"react-dom",
3333
);
34+
config.devServer = {
35+
hot: true,
36+
};
3437
// Important: return the modified config
3538
return config;
3639
},

src/packages/next/pages/index.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import { Layout, Tooltip } from "antd";
77
import { GetServerSidePropsContext } from "next";
88
import { join } from "path";
9-
109
import { getRecentHeadlines } from "@cocalc/database/postgres/news";
1110
import { COLORS } from "@cocalc/util/theme";
1211
import { RecentHeadline } from "@cocalc/util/types/news";
@@ -84,7 +83,7 @@ export default function Home(props: Props) {
8483
}}
8584
>
8685
<Title level={1} style={{ color: COLORS.GRAY }}>
87-
Signed in as{" "}
86+
Signed in as{" "}
8887
<Tooltip title={"View all your account settings"} placement={"right"}>
8988
<a href={join(basePath, "settings")}>
9089
{`${account.first_name} ${account.last_name} ${

0 commit comments

Comments
 (0)