@@ -11,10 +11,12 @@ import { versionCheckFails } from "./version";
11
11
import { proxyConatWebsocket } from "./proxy-conat" ;
12
12
import basePath from "@cocalc/backend/base-path" ;
13
13
14
+ const LISTENERS_HACK = true ;
15
+
14
16
const logger = getLogger ( "proxy:handle-upgrade" ) ;
15
17
16
- export default function init (
17
- { projectControl, isPersonal, httpServer, listenersHack , proxyConat } ,
18
+ export default function initUpgrade (
19
+ { projectControl, isPersonal, httpServer, proxyConat } ,
18
20
proxy_regexp : string ,
19
21
) {
20
22
const cache = new LRU < string , ProxyServer > ( {
@@ -24,22 +26,72 @@ export default function init(
24
26
25
27
const re = new RegExp ( proxy_regexp ) ;
26
28
29
+ let nextUpgrade : undefined | Function = undefined ;
30
+ let socketioUpgrade : undefined | Function = undefined ;
31
+
27
32
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
+ }
37
80
}
38
81
}
39
82
83
+ if ( proxyConat && useSocketio ( req . url ) ) {
84
+ proxyConatWebsocket ( req , socket , head ) ;
85
+ return ;
86
+ }
87
+
40
88
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 ) ;
43
95
return ;
44
96
}
45
97
@@ -128,80 +180,15 @@ export default function init(
128
180
proxy . ws ( req , socket , head ) ;
129
181
}
130
182
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
+ } ;
205
192
206
193
return handler ;
207
194
}
@@ -210,3 +197,12 @@ function denyUpgrade(socket) {
210
197
socket . write ( "HTTP/1.1 401 Unauthorized\r\n\r\n" ) ;
211
198
socket . destroy ( ) ;
212
199
}
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
+ }
0 commit comments