* Forward an HTTP or WebSocket request through to a provider-routed target. * * Shared by all four catch-all proxy branches (`/i/:instanceId/*`, host-based, * cookie-routed, and default personal). `logTag` is prepended to console logs * so failing requests can be traced back to their originating
(opts: {
request: Request;
targetUrl: string;
forwardHeaders: Headers;
logTag: string;
unreachableHint?: string;
startingUpHint?: string;
})
| 178 | targetUrl: string; |
| 179 | forwardHeaders: Headers; |
| 180 | logTag: string; |
| 181 | unreachableHint?: string; |
| 182 | startingUpHint?: string; |
| 183 | }): Promise<Response> { |
| 184 | const { request, targetUrl, forwardHeaders, logTag, unreachableHint, startingUpHint } = opts; |
| 185 | const isWebSocketRequest = request.headers.get('Upgrade')?.toLowerCase() === 'websocket'; |
| 186 | |
| 187 | const unreachableBody: Record<string, string> = { error: 'Instance not reachable' }; |
| 188 | if (unreachableHint) unreachableBody.hint = unreachableHint; |
| 189 | const startingUpBody: Record<string, string> = { error: 'Instance is starting up' }; |
| 190 | if (startingUpHint) startingUpBody.hint = startingUpHint; |
| 191 | |
| 192 | if (isWebSocketRequest) { |
| 193 | let containerResponse: Response; |
| 194 | try { |
| 195 | containerResponse = await fetch(targetUrl, { headers: forwardHeaders }); |
| 196 | } catch (err) { |
| 197 | console.error(`${logTag} WS fetch failed:`, err); |
| 198 | return Response.json(unreachableBody, { |
| 199 | status: 503, |
| 200 | headers: { 'Retry-After': '5' }, |
| 201 | }); |
| 202 | } |
| 203 | |
| 204 | if (containerResponse.status === 502) { |
| 205 | return Response.json(startingUpBody, { |
| 206 | status: 503, |
| 207 | headers: { 'Retry-After': '5' }, |
| 208 | }); |
| 209 | } |
| 210 | |
| 211 | const containerWs = containerResponse.webSocket; |
| 212 | if (!containerWs) { |
| 213 | // Upstream returned a non-upgrade response to a WebSocket request. |
| 214 | // Normalize to 502 JSON rather than leaking the raw upstream body — |
| 215 | // this path is only hit when the container is in a bad state |
| 216 | // (gateway crash, proxy misconfig) and the raw response may contain |
| 217 | // provider/controller error details we don't want to surface to |
| 218 | // the Control UI. |
| 219 | console.warn(`${logTag} upstream did not upgrade (status ${containerResponse.status})`); |
| 220 | return Response.json({ error: 'WebSocket upgrade failed' }, { status: 502 }); |
| 221 | } |
| 222 | |
| 223 | const [clientWs, serverWs] = Object.values(new WebSocketPair()); |
| 224 | serverWs.accept(); |
| 225 | containerWs.accept(); |
| 226 | |
| 227 | let droppedToContainer = 0; |
| 228 | let droppedToClient = 0; |
| 229 | |
| 230 | serverWs.addEventListener('message', event => { |
| 231 | if (containerWs.readyState === WebSocket.OPEN) { |
| 232 | containerWs.send(event.data as string | ArrayBuffer); |
| 233 | } else { |
| 234 | droppedToContainer++; |
| 235 | if (droppedToContainer === 1) { |
| 236 | console.warn( |
| 237 | `${logTag} First dropped client->container message (readyState:`, |
no test coverage detected