Skip to content

Commit 095a379

Browse files
Remote Agentclaude
andcommitted
fix: resolve editor proxy WebSocket handshake timeout
Replace .resolve() with .derive() for passing request URL to WebSocket handlers — resolve can fail to deliver data to ws handlers in Elysia, causing wsOpen to throw silently and never creating the upstream connection. Add fallback URL reconstruction from params/query, wrap wsOpen in try-catch, add upstream connection timeout, and log WS events for diagnostics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 48077b3 commit 095a379

1 file changed

Lines changed: 90 additions & 50 deletions

File tree

packages/api/src/routes/editor-proxy.routes.ts

Lines changed: 90 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -154,63 +154,102 @@ for (const path of paths) {
154154
const upstreamSockets = new WeakMap<object, WebSocket>();
155155
const messageBuffers = new WeakMap<object, (string | ArrayBuffer)[]>();
156156

157-
function wsOpen(ws: any) {
158-
const { params, requestUrl } = ws.data;
159-
const editorId = params.editorId;
160-
161-
const editor = codeEditorService.getEditor(editorId);
162-
if (!editor || (editor.status !== 'running' && editor.status !== 'starting')) {
163-
ws.close();
164-
return;
157+
function buildUpstreamWsUrl(ws: any, editorId: string, port: number): string {
158+
// Primary: use requestUrl captured by derive()
159+
const requestUrl: string | undefined = ws.data.requestUrl;
160+
if (requestUrl) {
161+
const url = new URL(requestUrl);
162+
const strippedPath = stripProxyPrefix(url.pathname, editorId);
163+
return `ws://127.0.0.1:${port}${strippedPath}${url.search}`;
165164
}
166165

167-
const url = new URL(requestUrl);
168-
const strippedPath = stripProxyPrefix(url.pathname, editorId);
169-
const targetUrl = `ws://127.0.0.1:${editor.port}${strippedPath}${url.search}`;
170-
171-
// Forward Sec-WebSocket-Protocol if the client sent one
172-
const protocolHeader: string | undefined = ws.data.headers?.['sec-websocket-protocol'];
173-
const protocols = protocolHeader
174-
? protocolHeader.split(',').map((p: string) => p.trim())
175-
: undefined;
166+
// Fallback: reconstruct from route params and query
167+
console.warn('[EditorProxy] WS: requestUrl missing from context, reconstructing from params/query');
168+
const wildcardPath = ws.data.params['*'] || '';
169+
const forwardPath = wildcardPath ? `/${wildcardPath}` : '/';
170+
const queryObj = ws.data.query || {};
171+
const qs = new URLSearchParams(queryObj as Record<string, string>).toString();
172+
return `ws://127.0.0.1:${port}${forwardPath}${qs ? '?' + qs : ''}`;
173+
}
176174

177-
const upstream = new WebSocket(targetUrl, protocols);
178-
upstream.binaryType = 'arraybuffer';
179-
upstreamSockets.set(ws, upstream);
175+
function wsOpen(ws: any) {
176+
const editorId = ws.data.params.editorId;
180177

181-
const buffer: (string | ArrayBuffer)[] = [];
182-
messageBuffers.set(ws, buffer);
178+
try {
179+
const editor = codeEditorService.getEditor(editorId);
180+
if (!editor || (editor.status !== 'running' && editor.status !== 'starting')) {
181+
console.warn(`[EditorProxy] WS: Editor ${editorId} not available (status: ${editor?.status ?? 'not found'})`);
182+
ws.close();
183+
return;
184+
}
183185

184-
upstream.addEventListener('open', () => {
185-
for (const msg of buffer) upstream.send(msg);
186-
buffer.length = 0;
187-
});
186+
const targetUrl = buildUpstreamWsUrl(ws, editorId, editor.port);
187+
console.log(`[EditorProxy] WS open: editor=${editorId}${targetUrl}`);
188+
189+
// Forward Sec-WebSocket-Protocol if the client sent one
190+
const headers = ws.data.headers || {};
191+
const protocolHeader: string | undefined = headers['sec-websocket-protocol'];
192+
const protocols = protocolHeader
193+
? protocolHeader.split(',').map((p: string) => p.trim())
194+
: undefined;
195+
196+
const upstream = protocols?.length
197+
? new WebSocket(targetUrl, protocols)
198+
: new WebSocket(targetUrl);
199+
upstream.binaryType = 'arraybuffer';
200+
upstreamSockets.set(ws, upstream);
201+
202+
const buffer: (string | ArrayBuffer)[] = [];
203+
messageBuffers.set(ws, buffer);
204+
205+
upstream.addEventListener('open', () => {
206+
console.log(`[EditorProxy] WS upstream connected: editor=${editorId}`);
207+
for (const msg of buffer) upstream.send(msg);
208+
buffer.length = 0;
209+
});
188210

189-
upstream.addEventListener('message', (event: MessageEvent) => {
190-
try {
191-
if (typeof event.data === 'string') {
192-
ws.send(event.data);
193-
} else {
194-
// Must use ws.raw.send() for binary data — Elysia's ws.send()
195-
// JSON-stringifies objects (including Uint8Array), corrupting binary frames.
196-
ws.raw.send(new Uint8Array(event.data as ArrayBuffer));
211+
upstream.addEventListener('message', (event: MessageEvent) => {
212+
try {
213+
if (typeof event.data === 'string') {
214+
ws.send(event.data);
215+
} else {
216+
// Must use ws.raw.send() for binary data — Elysia's ws.send()
217+
// JSON-stringifies objects (including Uint8Array), corrupting binary frames.
218+
ws.raw.send(new Uint8Array(event.data as ArrayBuffer));
219+
}
220+
} catch {
221+
// Client already disconnected
197222
}
198-
} catch {
199-
// Client already disconnected
200-
}
201-
});
223+
});
202224

203-
upstream.addEventListener('close', () => {
204-
upstreamSockets.delete(ws);
205-
messageBuffers.delete(ws);
206-
try { ws.close(); } catch {}
207-
});
225+
upstream.addEventListener('close', (event: CloseEvent) => {
226+
console.log(`[EditorProxy] WS upstream closed: editor=${editorId} code=${event.code}`);
227+
upstreamSockets.delete(ws);
228+
messageBuffers.delete(ws);
229+
try { ws.close(); } catch {}
230+
});
208231

209-
upstream.addEventListener('error', () => {
210-
upstreamSockets.delete(ws);
211-
messageBuffers.delete(ws);
232+
upstream.addEventListener('error', (event: Event) => {
233+
console.error(`[EditorProxy] WS upstream error: editor=${editorId}`, event);
234+
upstreamSockets.delete(ws);
235+
messageBuffers.delete(ws);
236+
try { ws.close(); } catch {}
237+
});
238+
239+
// Safety timeout: if upstream never connects, clean up
240+
setTimeout(() => {
241+
if (upstream.readyState === WebSocket.CONNECTING) {
242+
console.error(`[EditorProxy] WS upstream connect timeout (10s): editor=${editorId}`);
243+
upstream.close();
244+
upstreamSockets.delete(ws);
245+
messageBuffers.delete(ws);
246+
try { ws.close(); } catch {}
247+
}
248+
}, 10_000);
249+
} catch (err) {
250+
console.error(`[EditorProxy] WS open error: editor=${editorId}`, err);
212251
try { ws.close(); } catch {}
213-
});
252+
}
214253
}
215254

216255
function wsMessage(ws: any, message: string | Buffer) {
@@ -247,10 +286,11 @@ function wsClose(ws: any) {
247286

248287
const wsHandler = { open: wsOpen, message: wsMessage, close: wsClose };
249288

250-
// .resolve() captures request URL and headers for the ws open handler (runs before upgrade).
251-
base.resolve(({ request }) => ({
289+
// derive() captures the raw request URL for the ws open handler.
290+
// Using derive (not resolve) because resolve can fail to pass data to ws handlers in Elysia.
291+
// We only store requestUrl — Elysia's default ws.data.headers already has request headers.
292+
base.derive(({ request }) => ({
252293
requestUrl: request.url,
253-
headers: Object.fromEntries(request.headers.entries()),
254294
}));
255295

256296
for (const path of paths) {

0 commit comments

Comments
 (0)